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 (
);
} ) }