/** * External dependencies */ import clsx from 'clsx'; import type { ComponentProps, ReactElement } from 'react'; /** * WordPress dependencies */ import { __, sprintf, isRTL } from '@wordpress/i18n'; import { Spinner, Popover } from '@wordpress/components'; import { useContext, useEffect, useId, useRef, useState, } from '@wordpress/element'; import { isAppleOS } from '@wordpress/keycodes'; /** * Internal dependencies */ import DataViewsContext from '../../dataviews-context'; import DataViewsSelectionCheckbox from '../../dataviews-selection-checkbox'; import ItemActions from '../../dataviews-item-actions'; import { sortValues } from '../../../constants'; import { useSomeItemHasAPossibleBulkAction, useHasAPossibleBulkAction, BulkSelectionCheckbox, } from '../../dataviews-bulk-actions'; import type { Action, NormalizedField, ViewTable as ViewTableType, ViewTableProps, } from '../../../types'; import type { SetSelection } from '../../../types/private'; import ColumnHeaderMenu from './column-header-menu'; import ColumnPrimary from './column-primary'; import { useScrollState } from './use-scroll-state'; import getDataByGroup from '../utils/get-data-by-group'; import { PropertiesSection } from '../../dataviews-view-config/properties-section'; import { useDelayedLoading } from '../../../hooks/use-delayed-loading'; function getEffectiveAlign( explicitAlign: 'start' | 'center' | 'end' | undefined, fieldType: string | undefined ): 'start' | 'center' | 'end' | undefined { if ( explicitAlign ) { return explicitAlign; } if ( fieldType === 'integer' || fieldType === 'number' ) { return 'end'; } return undefined; } interface TableColumnFieldProps< Item > { fields: NormalizedField< Item >[]; column: string; item: Item; align?: 'start' | 'center' | 'end'; } interface TableRowProps< Item > { hasBulkActions: boolean; item: Item; level?: number; actions: Action< Item >[]; fields: NormalizedField< Item >[]; id: string; view: ViewTableType; titleField?: NormalizedField< Item >; mediaField?: NormalizedField< Item >; descriptionField?: NormalizedField< Item >; selection: string[]; getItemId: ( item: Item ) => string; onChangeSelection: SetSelection; isItemClickable: ( item: Item ) => boolean; onClickItem?: ( item: Item ) => void; renderItemLink?: ( props: { item: Item; } & ComponentProps< 'a' > ) => ReactElement; isActionsColumnSticky?: boolean; posinset?: number; } function TableColumnField< Item >( { item, fields, column, align, }: TableColumnFieldProps< Item > ) { const field = fields.find( ( f ) => f.id === column ); if ( ! field ) { return null; } const className = clsx( 'dataviews-view-table__cell-content-wrapper', { 'dataviews-view-table__cell-align-end': align === 'end', 'dataviews-view-table__cell-align-center': align === 'center', } ); return (
); } function TableRow< Item >( { hasBulkActions, item, level, actions, fields, id, view, titleField, mediaField, descriptionField, selection, getItemId, isItemClickable, onClickItem, renderItemLink, onChangeSelection, isActionsColumnSticky, posinset, }: TableRowProps< Item > ) { const { paginationInfo } = useContext( DataViewsContext ); const hasPossibleBulkAction = useHasAPossibleBulkAction( actions, item ); const isSelected = hasPossibleBulkAction && selection.includes( id ); const { showTitle = true, showMedia = true, showDescription = true, infiniteScrollEnabled, } = view; // Will be set to true if `onTouchStart` fires. This happens before // `onClick` and can be used to exclude touchscreen devices from certain // behaviours. const isTouchDeviceRef = useRef( false ); const columns = view.fields ?? []; const hasPrimaryColumn = ( titleField && showTitle ) || ( mediaField && showMedia ) || ( descriptionField && showDescription ); return ( { isTouchDeviceRef.current = true; } } aria-setsize={ infiniteScrollEnabled ? paginationInfo.totalItems : undefined } aria-posinset={ posinset } role={ infiniteScrollEnabled ? 'article' : undefined } onMouseDown={ ( event ) => { // Firefox has a unique feature where ctrl/cmd + click selects a // table cell. This interferes with the bulk selection behavior, // so this code prevents it. const isMetaClick = isAppleOS() ? event.metaKey : event.ctrlKey; if ( event.button === 0 && isMetaClick && window.navigator.userAgent .toLowerCase() .includes( 'firefox' ) ) { event?.preventDefault(); } } } onClick={ ( event ) => { if ( ! hasPossibleBulkAction ) { return; } // Only handle Ctrl/Cmd+Click for multi-selection const isModifierKeyPressed = isAppleOS() ? event.metaKey : event.ctrlKey; if ( isModifierKeyPressed && ! isTouchDeviceRef.current && document.getSelection()?.type !== 'Range' ) { // Handle non-consecutive selection with Ctrl/Cmd+Click onChangeSelection( selection.includes( id ) ? selection.filter( ( itemId ) => id !== itemId ) : [ ...selection, id ] ); } } } > { hasBulkActions && (
) } { hasPrimaryColumn && ( ) } { columns.map( ( column: string ) => { // Explicit picks the supported styles. const { width, maxWidth, minWidth, align } = view.layout?.styles?.[ column ] ?? {}; const field = fields.find( ( f ) => f.id === column ); const effectiveAlign = getEffectiveAlign( align, field?.type ); return ( ); } ) } { !! actions?.length && ( // Disable reason: we are not making the element interactive, // but preventing any click events from bubbling up to the // table row. This allows us to add a click handler to the row // itself (to toggle row selection) without erroneously // intercepting click events from ItemActions. /* eslint-disable jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/click-events-have-key-events */ e.stopPropagation() } > /* eslint-enable jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/click-events-have-key-events */ ) } ); } function ViewTable< Item >( { actions, data, fields, getItemId, getItemLevel, isLoading = false, onChangeView, onChangeSelection, selection, setOpenedFilter, onClickItem, isItemClickable, renderItemLink, view, className, empty, }: ViewTableProps< Item > ) { const { containerRef } = useContext( DataViewsContext ); const isDelayedLoading = useDelayedLoading( isLoading ); const headerMenuRefs = useRef< Map< string, { node: HTMLButtonElement; fallback: string } > >( new Map() ); const headerMenuToFocusRef = useRef< HTMLButtonElement >( undefined ); const [ nextHeaderMenuToFocus, setNextHeaderMenuToFocus ] = useState< HTMLButtonElement >(); const [ contextMenuAnchor, setContextMenuAnchor ] = useState< { getBoundingClientRect: () => DOMRect; } | null >( null ); useEffect( () => { if ( headerMenuToFocusRef.current ) { headerMenuToFocusRef.current.focus(); headerMenuToFocusRef.current = undefined; } } ); const tableNoticeId = useId(); const { isHorizontalScrollEnd, isVerticallyScrolled } = useScrollState( { scrollContainerRef: containerRef, enabledHorizontal: !! actions?.length, } ); const hasBulkActions = useSomeItemHasAPossibleBulkAction( actions, data ); if ( nextHeaderMenuToFocus ) { // If we need to force focus, we short-circuit rendering here // to prevent any additional work while we handle that. // Clearing out the focus directive is necessary to make sure // future renders don't cause unexpected focus jumps. headerMenuToFocusRef.current = nextHeaderMenuToFocus; setNextHeaderMenuToFocus( undefined ); return; } const onHide = ( field: NormalizedField< Item > ) => { const hidden = headerMenuRefs.current.get( field.id ); const fallback = hidden ? headerMenuRefs.current.get( hidden.fallback ) : undefined; setNextHeaderMenuToFocus( fallback?.node ); }; const handleHeaderContextMenu = ( event: React.MouseEvent ) => { event.preventDefault(); event.stopPropagation(); const virtualAnchor = { getBoundingClientRect: () => ( { x: event.clientX, y: event.clientY, top: event.clientY, left: event.clientX, right: event.clientX, bottom: event.clientY, width: 0, height: 0, toJSON: () => ( {} ), } ), }; window.requestAnimationFrame( () => { setContextMenuAnchor( virtualAnchor ); } ); }; const hasData = !! data?.length; 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 groupField = view.groupBy?.field ? fields.find( ( f ) => f.id === view.groupBy?.field ) : null; const dataByGroup = groupField ? getDataByGroup( data, groupField ) : null; const { showTitle = true, showMedia = true, showDescription = true } = view; const hasPrimaryColumn = ( titleField && showTitle ) || ( mediaField && showMedia ) || ( descriptionField && showDescription ); const columns = view.fields ?? []; const headerMenuRef = ( column: string, index: number ) => ( node: HTMLButtonElement ) => { if ( node ) { headerMenuRefs.current.set( column, { node, fallback: columns[ index > 0 ? index - 1 : 1 ], } ); } else { headerMenuRefs.current.delete( column ); } }; const isInfiniteScroll = view.infiniteScrollEnabled && ! dataByGroup; const isRtl = isRTL(); if ( ! hasData ) { return (
{ empty }
); } return ( <> { hasBulkActions && ( ) } { hasPrimaryColumn && ( ) } { columns.map( ( column, index ) => ( ) ) } { !! actions?.length && ( ) } { contextMenuAnchor && ( setContextMenuAnchor( null ) } placement="bottom-start" > ) } { hasBulkActions && ( ) } { hasPrimaryColumn && ( ) } { columns.map( ( column, index ) => { // Explicit picks the supported styles. const { width, maxWidth, minWidth, align } = view.layout?.styles?.[ column ] ?? {}; const field = fields.find( ( f ) => f.id === column ); const effectiveAlign = getEffectiveAlign( align, field?.type ); const canInsertOrMove = view.layout?.enableMoving ?? true; return ( ); } ) } { !! actions?.length && ( ) } { /* Render grouped data if groupBy is specified */ } { hasData && groupField && dataByGroup ? ( Array.from( dataByGroup.entries() ).map( ( [ groupName, groupItems ] ) => ( { groupItems.map( ( item, index ) => ( ) ) } ) ) ) : ( { hasData && data.map( ( item, index ) => ( ) ) } ) }
{ titleField && ( ) } { __( 'Actions' ) }
{ 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 ) }
{ isInfiniteScroll && isLoading && (

) } ); } export default ViewTable;