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