-
Notifications
You must be signed in to change notification settings - Fork 4.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[Data Views] Updating keyboard navigation in list layouts #59637
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -6,17 +6,135 @@ import classNames from 'classnames'; | |
/** | ||
* WordPress dependencies | ||
*/ | ||
import { useAsyncList } from '@wordpress/compose'; | ||
import { useAsyncList, useInstanceId } from '@wordpress/compose'; | ||
import { | ||
__experimentalHStack as HStack, | ||
__experimentalVStack as VStack, | ||
privateApis as componentsPrivateApis, | ||
Button, | ||
Spinner, | ||
VisuallyHidden, | ||
} from '@wordpress/components'; | ||
import { ENTER, SPACE } from '@wordpress/keycodes'; | ||
import { useCallback, useEffect, useRef } from '@wordpress/element'; | ||
import { info } from '@wordpress/icons'; | ||
import { __ } from '@wordpress/i18n'; | ||
|
||
/** | ||
* Internal dependencies | ||
*/ | ||
import { unlock } from './lock-unlock'; | ||
|
||
const { | ||
useCompositeStoreV2: useCompositeStore, | ||
CompositeV2: Composite, | ||
CompositeItemV2: CompositeItem, | ||
CompositeRowV2: CompositeRow, | ||
} = unlock( componentsPrivateApis ); | ||
|
||
function ListItem( { | ||
id, | ||
item, | ||
isSelected, | ||
onSelect, | ||
onDetailsChange, | ||
mediaField, | ||
primaryField, | ||
visibleFields, | ||
} ) { | ||
const itemRef = useRef( null ); | ||
const labelId = `${ id }-label`; | ||
const descriptionId = `${ id }-description`; | ||
|
||
useEffect( () => { | ||
if ( isSelected ) { | ||
itemRef.current?.scrollIntoView( { | ||
behavior: 'auto', | ||
block: 'nearest', | ||
inline: 'nearest', | ||
} ); | ||
} | ||
}, [ isSelected ] ); | ||
|
||
return ( | ||
<CompositeRow | ||
ref={ itemRef } | ||
render={ <li /> } | ||
role="row" | ||
className={ classNames( { | ||
'is-selected': isSelected, | ||
} ) } | ||
> | ||
<HStack className="dataviews-view-list__item-wrapper"> | ||
<div role="gridcell"> | ||
<CompositeItem | ||
render={ <div /> } | ||
role="button" | ||
id={ id } | ||
aria-pressed={ isSelected } | ||
aria-labelledby={ labelId } | ||
aria-describedby={ descriptionId } | ||
className="dataviews-view-list__item" | ||
onClick={ () => onSelect( item ) } | ||
> | ||
<HStack | ||
spacing={ 3 } | ||
justify="start" | ||
alignment="flex-start" | ||
> | ||
<div className="dataviews-view-list__media-wrapper"> | ||
{ mediaField?.render( { item } ) || ( | ||
<div className="dataviews-view-list__media-placeholder"></div> | ||
) } | ||
</div> | ||
<VStack spacing={ 1 }> | ||
<span | ||
className="dataviews-view-list__primary-field" | ||
id={ labelId } | ||
> | ||
{ primaryField?.render( { item } ) } | ||
</span> | ||
<div | ||
className="dataviews-view-list__fields" | ||
id={ descriptionId } | ||
> | ||
{ visibleFields.map( ( field ) => ( | ||
<p | ||
key={ field.id } | ||
className="dataviews-view-list__field" | ||
> | ||
<VisuallyHidden | ||
as="span" | ||
className="dataviews-view-list__field-label" | ||
> | ||
{ field.header } | ||
</VisuallyHidden> | ||
<span className="dataviews-view-list__field-value"> | ||
{ field.render( { item } ) } | ||
</span> | ||
</p> | ||
) ) } | ||
</div> | ||
</VStack> | ||
</HStack> | ||
</CompositeItem> | ||
</div> | ||
{ onDetailsChange && ( | ||
<div role="gridcell"> | ||
<CompositeItem | ||
render={ <Button /> } | ||
className="dataviews-view-list__details-button" | ||
onClick={ () => onDetailsChange( [ item ] ) } | ||
icon={ info } | ||
label={ __( 'View details' ) } | ||
size="compact" | ||
/> | ||
</div> | ||
) } | ||
</HStack> | ||
</CompositeRow> | ||
); | ||
} | ||
|
||
export default function ViewList( { | ||
view, | ||
fields, | ||
|
@@ -27,9 +145,15 @@ export default function ViewList( { | |
onDetailsChange, | ||
selection, | ||
deferredRendering, | ||
id: preferredId, | ||
} ) { | ||
const baseId = useInstanceId( ViewList, 'view-list', preferredId ); | ||
const shownData = useAsyncList( data, { step: 3 } ); | ||
const usedData = deferredRendering ? shownData : data; | ||
const selectedItem = usedData?.findLast( ( item ) => | ||
selection.includes( item.id ) | ||
); | ||
|
||
const mediaField = fields.find( | ||
( field ) => field.id === view.layout.mediaField | ||
); | ||
|
@@ -44,12 +168,19 @@ export default function ViewList( { | |
) | ||
); | ||
|
||
const onEnter = ( item ) => ( event ) => { | ||
const { keyCode } = event; | ||
if ( [ ENTER, SPACE ].includes( keyCode ) ) { | ||
onSelectionChange( [ item ] ); | ||
} | ||
}; | ||
const onSelect = useCallback( | ||
( item ) => onSelectionChange( [ item ] ), | ||
[ onSelectionChange ] | ||
); | ||
|
||
const getItemDomId = useCallback( | ||
( item ) => ( item ? `${ baseId }-${ getItemId( item ) }` : undefined ), | ||
[ baseId, getItemId ] | ||
); | ||
|
||
const store = useCompositeStore( { | ||
defaultActiveId: getItemDomId( selectedItem ), | ||
} ); | ||
|
||
const hasData = usedData?.length; | ||
if ( ! hasData ) { | ||
|
@@ -68,70 +199,29 @@ export default function ViewList( { | |
} | ||
|
||
return ( | ||
<ul className="dataviews-view-list"> | ||
<Composite | ||
id={ baseId } | ||
render={ <ul /> } | ||
className="dataviews-view-list" | ||
role="grid" | ||
store={ store } | ||
> | ||
{ usedData.map( ( item ) => { | ||
const id = getItemDomId( item ); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why not using There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this something related to how Composite/Ariakit works? Otherwise, I don't follow what the ids are for at this level. Judging by what I see at runtime, I presume 1) Ariakit needs this tracking and 2) we want to make sure the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm largely unfamiliar with Ariakit's Composite, but I find it weird that it 1) uses DOM ids to track the items and 2) the concept of a store attached to a component. I understand that, by using Ariakit's Composite, we want to hide a certain layer of complexity (handle onClick/onKeydown events + tab/arrow-key mechanics) though I'd argue that complexity is familiar for most people while the complexity added by Ariakit's Composite mechanics is unfamiliar for most. In terms of maintenance and evolution of this code, I feel a bit uneasy about this trade-off. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Asked around to some folks and others think this is fine. |
||
return ( | ||
<li | ||
key={ getItemId( item ) } | ||
className={ classNames( { | ||
'is-selected': selection.includes( item.id ), | ||
} ) } | ||
> | ||
<HStack className="dataviews-view-list__item-wrapper"> | ||
<div | ||
role="button" | ||
tabIndex={ 0 } | ||
aria-pressed={ selection.includes( item.id ) } | ||
onKeyDown={ onEnter( item ) } | ||
className="dataviews-view-list__item" | ||
onClick={ () => onSelectionChange( [ item ] ) } | ||
> | ||
<HStack | ||
spacing={ 3 } | ||
justify="start" | ||
alignment="flex-start" | ||
> | ||
<div className="dataviews-view-list__media-wrapper"> | ||
{ mediaField?.render( { item } ) || ( | ||
<div className="dataviews-view-list__media-placeholder"></div> | ||
) } | ||
</div> | ||
<VStack spacing={ 1 }> | ||
<span className="dataviews-view-list__primary-field"> | ||
{ primaryField?.render( { item } ) } | ||
</span> | ||
<div className="dataviews-view-list__fields"> | ||
{ visibleFields.map( ( field ) => { | ||
return ( | ||
<span | ||
key={ field.id } | ||
className="dataviews-view-list__field" | ||
> | ||
{ field.render( { | ||
item, | ||
} ) } | ||
</span> | ||
); | ||
} ) } | ||
</div> | ||
</VStack> | ||
</HStack> | ||
</div> | ||
{ onDetailsChange && ( | ||
<Button | ||
className="dataviews-view-list__details-button" | ||
onClick={ () => | ||
onDetailsChange( [ item ] ) | ||
} | ||
icon={ info } | ||
label={ __( 'View details' ) } | ||
size="compact" | ||
/> | ||
) } | ||
</HStack> | ||
</li> | ||
<ListItem | ||
key={ id } | ||
id={ id } | ||
item={ item } | ||
isSelected={ item === selectedItem } | ||
onSelect={ onSelect } | ||
onDetailsChange={ onDetailsChange } | ||
mediaField={ mediaField } | ||
primaryField={ primaryField } | ||
visibleFields={ visibleFields } | ||
/> | ||
); | ||
} ) } | ||
</ul> | ||
</Composite> | ||
); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This changes how selection works for the list item. Particularly, in the case of some items being pre-selected from a different view and then switch to the list view.
Before:
Gravacao.do.ecra.2024-03-08.as.14.31.42.mov
After:
Gravacao.do.ecra.2024-03-08.as.14.30.12.mov
Until we figure out whether it makes sense to have multiple selected elements in the list view, this is a good approach.