/** * External dependencies */ import clsx from 'clsx'; /** * WordPress dependencies */ import { useInstanceId, usePrevious } from '@wordpress/compose'; import { Button, privateApis as componentsPrivateApis, Spinner, VisuallyHidden, Composite, } from '@wordpress/components'; import { useCallback, useEffect, useMemo, useRef, useState, useContext, } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import { moreVertical } from '@wordpress/icons'; import { useRegistry } from '@wordpress/data'; import { Stack } from '@wordpress/ui'; /** * Internal dependencies */ import { unlock } from '../../../lock-unlock'; import { ActionsMenuGroup, ActionModal } from '../../dataviews-item-actions'; import DataViewsContext from '../../dataviews-context'; import { useDelayedLoading } from '../../../hooks/use-delayed-loading'; import type { Action, NormalizedField, ViewList as ViewListType, ViewListProps, ActionModal as ActionModalType, } from '../../../types'; import getDataByGroup from '../utils/get-data-by-group'; interface ListViewItemProps< Item > { view: ViewListType; actions: Action< Item >[]; idPrefix: string; isSelected: boolean; item: Item; titleField?: NormalizedField< Item >; mediaField?: NormalizedField< Item >; descriptionField?: NormalizedField< Item >; onSelect: ( item: Item ) => void; otherFields: NormalizedField< Item >[]; onDropdownTriggerKeyDown: React.KeyboardEventHandler< HTMLButtonElement >; posinset?: number; } const { Menu } = 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 } ); } } > { label } } />
); } function ListItem< Item >( { view, actions, idPrefix, isSelected, item, titleField, mediaField, descriptionField, onSelect, otherFields, onDropdownTriggerKeyDown, posinset, }: ListViewItemProps< Item > ) { const { showTitle = true, showMedia = true, showDescription = true, infiniteScrollEnabled, } = view; const itemRef = useRef< HTMLDivElement >( null ); const labelId = `${ idPrefix }-label`; const descriptionId = `${ idPrefix }-description`; const registry = useRegistry(); const [ isHovered, setIsHovered ] = useState( false ); const [ activeModalAction, setActiveModalAction ] = useState( null as ActionModalType< Item > | null ); const handleHover: React.MouseEventHandler = ( { type } ) => { const isHover = type === 'mouseenter'; setIsHovered( isHover ); }; const { paginationInfo } = useContext( DataViewsContext ); useEffect( () => { if ( isSelected ) { itemRef.current?.scrollIntoView( { behavior: 'auto', block: 'nearest', inline: 'nearest', } ); } }, [ isSelected ] ); const { primaryAction, eligibleActions } = useMemo( () => { // If an action is eligible for all items, doesn't need // to provide the `isEligible` function. const _eligibleActions = actions.filter( ( action ) => ! action.isEligible || action.isEligible( item ) ); const _primaryActions = _eligibleActions.filter( ( action ) => action.isPrimary ); return { primaryAction: _primaryActions[ 0 ], eligibleActions: _eligibleActions, }; }, [ actions, item ] ); const hasOnlyOnePrimaryAction = primaryAction && actions.length === 1; const renderedMediaField = showMedia && mediaField?.render ? (
) : null; const renderedTitleField = showTitle && titleField?.render ? ( ) : null; const renderDescription = showDescription && descriptionField?.render; // When we have only the media and title fields, we want to center them vertically in the list item. const hasOnlyMediaAndTitle = !! renderedMediaField && ! renderDescription && ! otherFields.length; const usedActions = eligibleActions?.length > 0 && ( { primaryAction && ( ) } { ! hasOnlyOnePrimaryAction && (
} /> } /> { !! activeModalAction && ( setActiveModalAction( null ) } /> ) }
) }
); return ( } role={ infiniteScrollEnabled ? 'article' : 'row' } className={ clsx( { 'is-selected': isSelected, 'is-hovered': isHovered, } ) } onMouseEnter={ handleHover } onMouseLeave={ handleHover } >
onSelect( item ) } />
{ renderedMediaField }
{ renderedTitleField }
{ usedActions }
{ renderDescription && (
) }
{ otherFields.map( ( field ) => (
{ field.label }
) ) }
); } function isDefined< T >( item: T | undefined ): item is T { return !! item; } export default function ViewList< Item >( props: ViewListProps< Item > ) { const { actions, data, fields, getItemId, isLoading, onChangeSelection, selection, view, className, empty, } = props; const baseId = useInstanceId( ViewList, 'view-list' ); const isDelayedLoading = useDelayedLoading( !! isLoading ); const selectedItem = data?.findLast( ( item ) => selection.includes( getItemId( item ) ) ); const titleField = fields.find( ( field ) => field.id === view.titleField ); const mediaField = fields.find( ( field ) => field.id === view.mediaField ); const descriptionField = fields.find( ( field ) => field.id === view.descriptionField ); const otherFields = ( view?.fields ?? [] ) .map( ( fieldId ) => fields.find( ( f ) => fieldId === f.id ) ) .filter( isDefined ); const onSelect = ( item: Item ) => onChangeSelection( [ getItemId( item ) ] ); const generateCompositeItemIdPrefix = useCallback( ( item: Item ) => `${ baseId }-${ getItemId( item ) }`, [ baseId, getItemId ] ); const isActiveCompositeItem = useCallback( ( item: Item, idToCheck: string ) => { // All composite items use the same prefix in their IDs. return idToCheck.startsWith( generateCompositeItemIdPrefix( item ) ); }, [ generateCompositeItemIdPrefix ] ); // Controlled state for the active composite item. const [ activeCompositeId, setActiveCompositeId ] = useState< string | null | undefined >( undefined ); const compositeRef = useRef< HTMLDivElement >( null ); // 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 ) ); if ( ! data[ clampedIndex ] ) { return; } const itemIdPrefix = generateCompositeItemIdPrefix( data[ clampedIndex ] ); const targetCompositeItemId = generateCompositeId( itemIdPrefix ); setActiveCompositeId( targetCompositeItemId ); // The active composite item is controlled state that // can update without needing a focus move (e.g., searching // can trigger an active ID update). Only move DOM focus // when it's already within the list. if ( compositeRef.current?.contains( compositeRef.current.ownerDocument.activeElement ) ) { document.getElementById( targetCompositeItemId )?.focus(); } }, [ data, generateCompositeItemIdPrefix ] ); // Select a new active composite item when the current active item // is removed from the list. useEffect( () => { 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, 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; const groupField = view.groupBy?.field ? fields.find( ( field ) => field.id === view.groupBy?.field ) : null; const dataByGroup = hasData && groupField ? getDataByGroup( data, groupField ) : null; const isInfiniteScroll = view.infiniteScrollEnabled && ! dataByGroup; if ( ! hasData ) { return (
{ empty }
); } // Render data grouped by field if ( hasData && groupField && dataByGroup ) { return ( } className="dataviews-view-list__group" role="grid" activeId={ activeCompositeId } setActiveId={ setActiveCompositeId } > { Array.from( dataByGroup.entries() ).map( ( [ groupName, groupItems ] ) => (

{ view.groupBy?.showLabel === false ? groupName : sprintf( // translators: 1: The label of the field e.g. "Date". 2: The value of the field, e.g.: "May 2022". __( '%1$s: %2$s' ), groupField.label, groupName ) }

{ groupItems.map( ( item ) => { const id = generateCompositeItemIdPrefix( item ); return ( ); } ) }
) ) }
); } // Render ungrouped data return ( <> } className={ clsx( 'dataviews-view-list', className, { [ `has-${ view.layout?.density }-density` ]: view.layout?.density && [ 'compact', 'comfortable' ].includes( view.layout.density ), 'is-refreshing': ! isInfiniteScroll && isDelayedLoading, } ) } role={ view.infiniteScrollEnabled ? 'feed' : 'grid' } activeId={ activeCompositeId } setActiveId={ setActiveCompositeId } // @ts-ignore inert={ ! isInfiniteScroll && !! isLoading ? 'true' : undefined } > { data.map( ( item, index ) => { const id = generateCompositeItemIdPrefix( item ); return ( ); } ) } { isInfiniteScroll && isLoading && (

) } ); }