diff --git a/packages/dataviews/src/dataviews-layouts/list/index.tsx b/packages/dataviews/src/dataviews-layouts/list/index.tsx index d8889f25b24e9..00146c3ee3522 100644 --- a/packages/dataviews/src/dataviews-layouts/list/index.tsx +++ b/packages/dataviews/src/dataviews-layouts/list/index.tsx @@ -2,17 +2,11 @@ * External dependencies */ import clsx from 'clsx'; -// TODO: use the @wordpress/components one once public -// eslint-disable-next-line no-restricted-imports -import { useStoreState } from '@ariakit/react'; -// Import CompositeStore type, which is not exported from @wordpress/components. -// eslint-disable-next-line no-restricted-imports -import type { CompositeStore } from '@ariakit/react'; /** * WordPress dependencies */ -import { useInstanceId } from '@wordpress/compose'; +import { useInstanceId, usePrevious } from '@wordpress/compose'; import { __experimentalHStack as HStack, __experimentalVStack as VStack, @@ -44,39 +38,115 @@ import type { Action, NormalizedField, ViewListProps } from '../../types'; interface ListViewItemProps< Item > { actions: Action< Item >[]; - id?: string; + idPrefix: string; isSelected: boolean; item: Item; mediaField?: NormalizedField< Item >; onSelect: ( item: Item ) => void; primaryField?: NormalizedField< Item >; - store: CompositeStore; visibleFields: NormalizedField< Item >[]; + onDropdownTriggerKeyDown: React.KeyboardEventHandler< HTMLButtonElement >; } const { - useCompositeStoreV2: useCompositeStore, CompositeV2: Composite, CompositeItemV2: CompositeItem, CompositeRowV2: CompositeRow, DropdownMenuV2: DropdownMenu, } = unlock( componentsPrivateApis ); +function generateItemWrapperCompositeId( idPrefix: string ) { + return `${ idPrefix }-item-wrapper`; +} +function generatePrimaryActionCompositeId( + idPrefix: string, + primaryActionId: string +) { + return `${ idPrefix }-primary-action-${ primaryActionId }`; +} +function generateDropdownTriggerCompositeId( idPrefix: string ) { + return `${ idPrefix }-dropdown`; +} + +function PrimaryActionGridCell< Item >( { + idPrefix, + primaryAction, + item, +}: { + idPrefix: string; + primaryAction: Action< Item >; + item: Item; +} ) { + const registry = useRegistry(); + const [ isModalOpen, setIsModalOpen ] = useState( false ); + + const compositeItemId = generatePrimaryActionCompositeId( + idPrefix, + primaryAction.id + ); + + const label = + typeof primaryAction.label === 'string' + ? primaryAction.label + : primaryAction.label( [ item ] ); + + return 'RenderModal' in primaryAction ? ( +
+ setIsModalOpen( true ) } + /> + } + > + { isModalOpen && ( + + action={ primaryAction } + items={ [ item ] } + closeModal={ () => setIsModalOpen( false ) } + /> + ) } + +
+ ) : ( +
+ { + primaryAction.callback( [ item ], { registry } ); + } } + /> + } + /> +
+ ); +} + function ListItem< Item >( { actions, - id, + idPrefix, isSelected, item, mediaField, onSelect, primaryField, - store, visibleFields, + onDropdownTriggerKeyDown, }: ListViewItemProps< Item > ) { - const registry = useRegistry(); const itemRef = useRef< HTMLElement >( null ); - const labelId = `${ id }-label`; - const descriptionId = `${ id }-description`; + const labelId = `${ idPrefix }-label`; + const descriptionId = `${ idPrefix }-description`; const [ isHovered, setIsHovered ] = useState( false ); const handleMouseEnter = () => { @@ -111,13 +181,6 @@ function ListItem< Item >( { }; }, [ actions, item ] ); - const [ isModalOpen, setIsModalOpen ] = useState( false ); - const primaryActionLabel = - primaryAction && - ( typeof primaryAction.label === 'string' - ? primaryAction.label - : primaryAction.label( [ item ] ) ); - const renderedMediaField = mediaField?.render ? ( ) : ( @@ -147,10 +210,9 @@ function ListItem< Item >( { >
} role="button" - id={ id } + id={ generateItemWrapperCompositeId( idPrefix ) } aria-pressed={ isSelected } aria-labelledby={ labelId } aria-describedby={ descriptionId } @@ -210,65 +272,20 @@ function ListItem< Item >( { width: 'auto', } } > - { primaryAction && 'RenderModal' in primaryAction && ( -
- - setIsModalOpen( true ) - } - /> - } - > - { isModalOpen && ( - - action={ primaryAction } - items={ [ item ] } - closeModal={ () => - setIsModalOpen( false ) - } - /> - ) } - -
+ { primaryAction && ( + ) } - { primaryAction && - ! ( 'RenderModal' in primaryAction ) && ( -
- { - primaryAction.callback( - [ item ], - { registry } - ); - } } - /> - } - /> -
- ) }
( { label={ __( 'Actions' ) } accessibleWhenDisabled disabled={ ! actions.length } - onKeyDown={ ( event: { - key: string; - preventDefault: () => void; - } ) => { - if ( - event.key === - 'ArrowDown' - ) { - // Prevent the default behaviour (open dropdown menu) and go down. - event.preventDefault(); - store.move( - store.down() - ); - } - if ( - event.key === 'ArrowUp' - ) { - // Prevent the default behavior (open dropdown menu) and go up. - event.preventDefault(); - store.move( - store.up() - ); - } - } } + onKeyDown={ + onDropdownTriggerKeyDown + } /> } /> @@ -331,6 +327,7 @@ export default function ViewList< Item >( props: ViewListProps< Item > ) { view, } = props; const baseId = useInstanceId( ViewList, 'view-list' ); + const selectedItem = data?.findLast( ( item ) => selection.includes( getItemId( item ) ) ); @@ -353,34 +350,108 @@ export default function ViewList< Item >( props: ViewListProps< Item > ) { const onSelect = ( item: Item ) => onChangeSelection( [ getItemId( item ) ] ); - const getItemDomId = useCallback( - ( item?: Item ) => - item ? `${ baseId }-${ getItemId( item ) }` : undefined, + const generateCompositeItemIdPrefix = useCallback( + ( item: Item ) => `${ baseId }-${ getItemId( item ) }`, [ baseId, getItemId ] ); - const store = useCompositeStore( { - defaultActiveId: getItemDomId( selectedItem ), - } ) as CompositeStore; // TODO, remove once composite APIs are public + const isActiveCompositeItem = useCallback( + ( item: Item, idToCheck: string ) => { + // All composite items use the same prefix in their IDs. + return idToCheck.startsWith( + generateCompositeItemIdPrefix( item ) + ); + }, + [ generateCompositeItemIdPrefix ] + ); - // Manage focused item, when the active one is removed from the list. - const isActiveIdInList = useStoreState( - store, - ( state: { items: any[]; activeId: any } ) => - state.items.some( - ( item: { id: any } ) => item.id === state.activeId - ) + // Controlled state for the active composite item. + const [ activeCompositeId, setActiveCompositeId ] = useState< + string | null | undefined + >( undefined ); + + // Update the active composite item when the selected item changes. + useEffect( () => { + if ( selectedItem ) { + setActiveCompositeId( + generateItemWrapperCompositeId( + generateCompositeItemIdPrefix( selectedItem ) + ) + ); + } + }, [ selectedItem, generateCompositeItemIdPrefix ] ); + + const activeItemIndex = data.findIndex( ( item ) => + isActiveCompositeItem( item, activeCompositeId ?? '' ) ); + const previousActiveItemIndex = usePrevious( activeItemIndex ); + const isActiveIdInList = activeItemIndex !== -1; + + const selectCompositeItem = useCallback( + ( + targetIndex: number, + // Allows invokers to specify a custom function to generate the + // target composite item ID + generateCompositeId: ( idPrefix: string ) => string + ) => { + // Clamping between 0 and data.length - 1 to avoid out of bounds. + const clampedIndex = Math.min( + data.length - 1, + Math.max( 0, targetIndex ) + ); + const itemIdPrefix = generateCompositeItemIdPrefix( + data[ clampedIndex ] + ); + const targetCompositeItemId = generateCompositeId( itemIdPrefix ); + + setActiveCompositeId( targetCompositeItemId ); + document.getElementById( targetCompositeItemId )?.focus(); + }, + [ data, generateCompositeItemIdPrefix ] + ); + + // Select a new active composite item when the current active item + // is removed from the list. useEffect( () => { - if ( ! isActiveIdInList ) { - // Prefer going down, except if there is no item below (last item), then go up (last item in list). - if ( store.down() ) { - store.move( store.down() ); - } else if ( store.up() ) { - store.move( store.up() ); - } + const wasActiveIdInList = + previousActiveItemIndex !== undefined && + previousActiveItemIndex !== -1; + if ( ! isActiveIdInList && wasActiveIdInList ) { + // By picking `previousActiveItemIndex` as the next item index, we are + // basically picking the item that would have been after the deleted one. + // If the previously active (and removed) item was the last of the list, + // we will select the item before it — which is the new last item. + selectCompositeItem( + previousActiveItemIndex, + generateItemWrapperCompositeId + ); } - }, [ isActiveIdInList ] ); + }, [ isActiveIdInList, selectCompositeItem, previousActiveItemIndex ] ); + + // Prevent the default behavior (open dropdown menu) and instead select the + // dropdown menu trigger on the previous/next row. + // https://github.com/ariakit/ariakit/issues/3768 + const onDropdownTriggerKeyDown = useCallback( + ( event: React.KeyboardEvent< HTMLButtonElement > ) => { + if ( event.key === 'ArrowDown' ) { + // Select the dropdown menu trigger item in the next row. + event.preventDefault(); + selectCompositeItem( + activeItemIndex + 1, + generateDropdownTriggerCompositeId + ); + } + if ( event.key === 'ArrowUp' ) { + // Select the dropdown menu trigger item in the previous row. + event.preventDefault(); + selectCompositeItem( + activeItemIndex - 1, + generateDropdownTriggerCompositeId + ); + } + }, + [ selectCompositeItem, activeItemIndex ] + ); const hasData = data?.length; if ( ! hasData ) { @@ -404,22 +475,23 @@ export default function ViewList< Item >( props: ViewListProps< Item > ) { render={
    } className="dataviews-view-list" role="grid" - store={ store } + activeId={ activeCompositeId } + setActiveId={ setActiveCompositeId } > { data.map( ( item ) => { - const id = getItemDomId( item ); + const id = generateCompositeItemIdPrefix( item ); return ( ); } ) }