/** * External dependencies */ import clsx from 'clsx'; import type { ComponentProps, ReactElement, HTMLAttributes } from 'react'; /** * WordPress dependencies */ import { Flex, FlexItem, Tooltip, Composite, privateApis as componentsPrivateApis, } from '@wordpress/components'; import { Stack } from '@wordpress/ui'; import { __, sprintf } from '@wordpress/i18n'; import { useInstanceId } from '@wordpress/compose'; import { isAppleOS } from '@wordpress/keycodes'; import { useCallback, useContext, useRef, forwardRef, } from '@wordpress/element'; /** * Internal dependencies */ import { unlock } from '../../../lock-unlock'; import ItemActions from '../../dataviews-item-actions'; import DataViewsSelectionCheckbox from '../../dataviews-selection-checkbox'; import DataViewsContext from '../../dataviews-context'; import { useHasAPossibleBulkAction, useSomeItemHasAPossibleBulkAction, } from '../../dataviews-bulk-actions'; import type { Action, NormalizedField, ViewGrid as ViewGridType, } from '../../../types'; import type { SetSelection } from '../../../types/private'; import { ItemClickWrapper } from '../utils/item-click-wrapper'; const { Badge } = unlock( componentsPrivateApis ); import { useGridColumns } from './preview-size-picker'; import { GridItems } from '../utils/grid-items'; import { useIntersectionObserver, usePlaceholdersNeeded, } from '../utils/use-infinite-scroll'; function chunk< T >( array: T[], size: number ): T[][] { const chunks: T[][] = []; for ( let i = 0, j = array.length; i < j; i += size ) { chunks.push( array.slice( i, i + size ) ); } return chunks; } interface GridItemProps< Item > extends HTMLAttributes< HTMLDivElement > { view: ViewGridType; selection: string[]; onChangeSelection: SetSelection; getItemId: ( item: Item ) => string; onClickItem?: ( item: Item ) => void; renderItemLink?: ( props: { item: Item; } & ComponentProps< 'a' > ) => ReactElement; isItemClickable: ( item: Item ) => boolean; item: Item; actions: Action< Item >[]; titleField?: NormalizedField< Item >; mediaField?: NormalizedField< Item >; descriptionField?: NormalizedField< Item >; regularFields: NormalizedField< Item >[]; badgeFields: NormalizedField< Item >[]; hasBulkActions: boolean; config: { sizes: string; }; posinset?: number; setsize?: number; } const GridItem = forwardRef< HTMLDivElement, GridItemProps< any > >( function GridItem( { view, selection, onChangeSelection, onClickItem, isItemClickable, renderItemLink, getItemId, item, actions, mediaField, titleField, descriptionField, regularFields, badgeFields, hasBulkActions, config, posinset, setsize, ...props }, forwardedRef ) { const { showTitle = true, showMedia = true, showDescription = true, } = view; const hasBulkAction = useHasAPossibleBulkAction( actions, item ); const id = getItemId( item ); const elementRef = useRef< HTMLDivElement | null >( null ); // Merge refs callback const setRefs = useCallback( ( node: HTMLDivElement | null ) => { elementRef.current = node; if ( typeof forwardedRef === 'function' ) { forwardedRef( node ); } else if ( forwardedRef ) { forwardedRef.current = node; } }, [ forwardedRef ] ); useIntersectionObserver( elementRef, posinset ); const instanceId = useInstanceId( GridItem ); const isSelected = selection.includes( id ); const mediaPlaceholder = ( ); const rendersMediaField = showMedia && mediaField?.render; const renderedMediaField = rendersMediaField ? ( ) : ( mediaPlaceholder ); const renderedTitleField = showTitle && titleField?.render ? ( ) : null; let mediaA11yProps; let titleA11yProps; if ( isItemClickable( item ) && onClickItem ) { if ( renderedTitleField ) { mediaA11yProps = { 'aria-labelledby': `dataviews-view-grid__title-field-${ instanceId }`, }; titleA11yProps = { id: `dataviews-view-grid__title-field-${ instanceId }`, }; } else { mediaA11yProps = { 'aria-label': __( 'Navigate to item' ), }; } } return ( { props.onClickCapture?.( event ); if ( isAppleOS() ? event.metaKey : event.ctrlKey ) { event.stopPropagation(); event.preventDefault(); if ( ! hasBulkAction ) { return; } onChangeSelection( isSelected ? selection.filter( ( itemId ) => id !== itemId ) : [ ...selection, id ] ); } } } > { renderedMediaField } { hasBulkActions && ( ) } { !! actions?.length && (
) } { showTitle && (
{ renderedTitleField }
) } { showDescription && descriptionField?.render && ( ) } { !! badgeFields?.length && ( { badgeFields.map( ( field ) => { return ( ); } ) } ) } { !! regularFields?.length && ( { regularFields.map( ( field ) => { return ( <> { field.header } ); } ) } ) }
); } ) as < Item >( props: GridItemProps< Item > & { ref?: React.ForwardedRef< HTMLDivElement >; } ) => React.ReactNode; interface CompositeGridProps< Item > { data: Item[]; isInfiniteScroll: boolean; className?: string; inert?: string; isLoading?: boolean; view: ViewGridType; fields: NormalizedField< Item >[]; selection: string[]; onChangeSelection: SetSelection; onClickItem?: ( item: Item ) => void; isItemClickable: ( item: Item ) => boolean; renderItemLink?: ( props: { item: Item; } & ComponentProps< 'a' > ) => ReactElement; getItemId: ( item: Item ) => string; actions: Action< Item >[]; } export default function CompositeGrid< Item >( { data, isInfiniteScroll, className, inert, isLoading, view, fields, selection, onChangeSelection, onClickItem, isItemClickable, renderItemLink, getItemId, actions, }: CompositeGridProps< Item > ) { const { paginationInfo, resizeObserverRef } = useContext( DataViewsContext ); const gridColumns = useGridColumns(); const hasBulkActions = useSomeItemHasAPossibleBulkAction( actions, data ); 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 ?? []; const { regularFields, badgeFields } = otherFields.reduce( ( accumulator: Record< string, NormalizedField< Item >[] >, fieldId ) => { const field = fields.find( ( f ) => f.id === fieldId ); if ( ! field ) { return accumulator; } // If the field is a badge field, add it to the badgeFields array // otherwise add it to the rest visibleFields array. const key = view.layout?.badgeFields?.includes( fieldId ) ? 'badgeFields' : 'regularFields'; accumulator[ key ].push( field ); return accumulator; }, { regularFields: [], badgeFields: [] } ); /* * This is the maximum width that an image can achieve in the grid. The reasoning is: * The biggest min image width available is 430px (see /dataviews-layouts/grid/preview-size-picker.tsx). * Because the grid is responsive, once there is room for another column, the images shrink to accommodate it. * So each image will never grow past 2*430px plus a little more to account for the gaps. */ const size = '900px'; const totalRows = Math.ceil( data.length / gridColumns ); // Calculate placeholders needed for infinite scroll const placeholdersNeeded = usePlaceholdersNeeded( data, isInfiniteScroll, gridColumns ); return ( <> { // Render infinite scroll layout (no rows, feed semantics) isInfiniteScroll && ( } role="feed" focusWrap // @ts-ignore inert={ inert } > { /* Render placeholders for unloaded items in first row */ } { Array.from( { length: placeholdersNeeded } ).map( ( _, index ) => ( ( ) } aria-hidden tabIndex={ -1 } /> ) ) } { data.map( ( item ) => { const itemId = getItemId( item ); // Use position from item for infinite scroll const stablePosition = ( item as any ).position; return ( ( ) } /> ); } ) } ) } { // Render standard grid layout (with rows, grid semantics) ! isInfiniteScroll && ( { chunk( data, gridColumns ).map( ( row, i ) => ( } > { row.map( ( item ) => { const itemId = getItemId( item ); return ( ( ) } /> ); } ) } ) ) } ) } ); }