From 5ea53bba5c4e89c66f483f34f4b765cba14016f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9?= <583546+oandregal@users.noreply.github.com> Date: Tue, 14 May 2024 10:54:48 +0200 Subject: [PATCH] DataViews: add actions to list layout (#60805) Co-authored-by: oandregal Co-authored-by: tyxla Co-authored-by: youknowriad Co-authored-by: DaniGuardiola Co-authored-by: jameskoster Co-authored-by: t-hamano Co-authored-by: mrfoxtalbot --- packages/dataviews/src/dataviews.js | 6 +- packages/dataviews/src/item-actions.js | 53 +++--- packages/dataviews/src/style.scss | 22 ++- packages/dataviews/src/view-grid.js | 8 +- packages/dataviews/src/view-list.tsx | 226 +++++++++++++++++++++++-- packages/dataviews/src/view-table.js | 8 +- 6 files changed, 276 insertions(+), 47 deletions(-) diff --git a/packages/dataviews/src/dataviews.js b/packages/dataviews/src/dataviews.js index 33f0da08c0ba55..75b672721f0419 100644 --- a/packages/dataviews/src/dataviews.js +++ b/packages/dataviews/src/dataviews.js @@ -128,16 +128,16 @@ export default function DataViews( { /> + + + ); +} + export function ActionWithModal( { action, items, @@ -64,34 +90,23 @@ export function ActionWithModal( { items, isBusy, }; - const { RenderModal, hideModalHeader } = action; return ( <> { isModalOpen && ( - { - setIsModalOpen( false ); - } } - overlayClassName={ `dataviews-action-modal dataviews-action-modal__${ kebabCase( - action.id - ) }` } - > - setIsModalOpen( false ) } - onActionStart={ onActionStart } - onActionPerformed={ onActionPerformed } - /> - + setIsModalOpen( false ) } + onActionStart={ onActionStart } + onActionPerformed={ onActionPerformed } + /> ) } ); } -function ActionsDropdownMenuGroup( { actions, item } ) { +export function ActionsDropdownMenuGroup( { actions, item } ) { return ( { actions.map( ( action ) => { diff --git a/packages/dataviews/src/style.scss b/packages/dataviews/src/style.scss index e8e8973ebf6799..aa46489db63924 100644 --- a/packages/dataviews/src/style.scss +++ b/packages/dataviews/src/style.scss @@ -401,6 +401,7 @@ li { margin: 0; cursor: pointer; + border-top: 1px solid $gray-100; .dataviews-view-list__item-wrapper { position: relative; @@ -411,6 +412,18 @@ } } + .dataviews-view-list__item-actions .components-button { + opacity: 0; + } + + &.is-selected, + &.is-hovered, + &:focus-within { + .dataviews-view-list__item-actions .components-button { + opacity: 1; + } + } + &:not(.is-selected) { .dataviews-view-list__primary-field { color: $gray-900; @@ -426,6 +439,7 @@ } } } + } li.is-selected, @@ -443,10 +457,9 @@ } .dataviews-view-list__item { - padding: $grid-unit-20 $grid-unit-40; + padding: $grid-unit-20 0 $grid-unit-20 $grid-unit-40; width: 100%; scroll-margin: $grid-unit-10 0; - border-top: 1px solid $gray-100; &:focus-visible { &::before { @@ -514,6 +527,11 @@ } } + .dataviews-view-list__item-actions { + padding-top: $grid-unit-20; + padding-right: $grid-unit-40; + } + & + .dataviews-pagination { justify-content: space-between; } diff --git a/packages/dataviews/src/view-grid.js b/packages/dataviews/src/view-grid.js index 978a39ddfef654..fa552c7c5afc7c 100644 --- a/packages/dataviews/src/view-grid.js +++ b/packages/dataviews/src/view-grid.js @@ -175,14 +175,14 @@ function GridItem( { } export default function ViewGrid( { + actions, data, fields, - view, - actions, - isLoading, getItemId, - selection, + isLoading, onSelectionChange, + selection, + view, } ) { const mediaField = fields.find( ( field ) => field.id === view.layout.mediaField diff --git a/packages/dataviews/src/view-list.tsx b/packages/dataviews/src/view-list.tsx index 193e89012fb895..022e2a7222f770 100644 --- a/packages/dataviews/src/view-list.tsx +++ b/packages/dataviews/src/view-list.tsx @@ -2,6 +2,9 @@ * External dependencies */ import clsx from 'clsx'; +// 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 @@ -10,12 +13,20 @@ import { useInstanceId } from '@wordpress/compose'; import { __experimentalHStack as HStack, __experimentalVStack as VStack, + Button, privateApis as componentsPrivateApis, Spinner, VisuallyHidden, } from '@wordpress/components'; -import { useCallback, useEffect, useRef } from '@wordpress/element'; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from '@wordpress/element'; import { __ } from '@wordpress/i18n'; +import { moreVertical } from '@wordpress/icons'; /** * Internal dependencies @@ -28,24 +39,41 @@ import type { ViewList as ViewListType, } from './types'; +import { ActionsDropdownMenuGroup, ActionModal } from './item-actions'; + +interface Action { + callback: ( items: Item[] ) => void; + icon: any; + id: string; + isDestructive: boolean | undefined; + isEligible: ( item: Item ) => boolean | undefined; + isPrimary: boolean | undefined; + label: string; + modalHeader: string; + RenderModal: ( props: any ) => JSX.Element; +} + interface ListViewProps { - view: ViewListType; - fields: NormalizedField[]; + actions: Action[]; data: Data; - isLoading: boolean; + fields: NormalizedField[]; getItemId: ( item: Item ) => string; + id: string; + isLoading: boolean; onSelectionChange: ( selection: Item[] ) => void; selection: Item[]; - id: string; + view: ViewListType; } interface ListViewItemProps { + actions: Action[]; id?: string; - item: Item; isSelected: boolean; - onSelect: ( item: Item ) => void; + item: Item; mediaField?: NormalizedField; + onSelect: ( item: Item ) => void; primaryField?: NormalizedField; + store: CompositeStore; visibleFields: NormalizedField[]; } @@ -54,21 +82,32 @@ const { CompositeV2: Composite, CompositeItemV2: CompositeItem, CompositeRowV2: CompositeRow, + DropdownMenuV2: DropdownMenu, } = unlock( componentsPrivateApis ); function ListItem( { + actions, id, - item, isSelected, - onSelect, + item, mediaField, + onSelect, primaryField, + store, visibleFields, }: ListViewItemProps ) { const itemRef = useRef< HTMLElement >( null ); const labelId = `${ id }-label`; const descriptionId = `${ id }-description`; + const [ isHovered, setIsHovered ] = useState( false ); + const handleMouseEnter = () => { + setIsHovered( true ); + }; + const handleMouseLeave = () => { + setIsHovered( false ); + }; + useEffect( () => { if ( isSelected ) { itemRef.current?.scrollIntoView( { @@ -79,6 +118,23 @@ function ListItem( { } }, [ 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 && !! action.icon + ); + return { + primaryAction: _primaryActions?.[ 0 ], + eligibleActions: _eligibleActions, + }; + }, [ actions, item ] ); + + const [ isModalOpen, setIsModalOpen ] = useState( false ); + return ( - +
} role="button" id={ id } @@ -142,6 +205,119 @@ function ListItem( {
+ { actions?.length > 0 && ( + + { primaryAction && !! primaryAction.RenderModal && ( +
+ + setIsModalOpen( true ) + } + /> + } + > + { isModalOpen && ( + + setIsModalOpen( false ) + } + onActionStart={ () => {} } + onActionPerformed={ () => {} } + /> + ) } + +
+ ) } + { primaryAction && ! primaryAction.RenderModal && ( +
+ + primaryAction.callback( [ + item, + ] ) + } + /> + } + /> +
+ ) } +
+ 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() + ); + } + } } + /> + } + /> + } + placement="bottom-end" + > + + +
+
+ ) }
); @@ -149,16 +325,16 @@ function ListItem( { export default function ViewList( props: ListViewProps ) { const { - view, - fields, + actions, data, - isLoading, + fields, getItemId, + isLoading, onSelectionChange, selection, - id: preferredId, + view, } = props; - const baseId = useInstanceId( ViewList, 'view-list', preferredId ); + const baseId = useInstanceId( ViewList, 'view-list' ); const selectedItem = data?.findLast( ( item ) => selection.includes( item.id ) ); @@ -192,6 +368,24 @@ export default function ViewList( props: ListViewProps ) { defaultActiveId: getItemDomId( selectedItem ), } ); + // Manage focused item, when the active one is removed from the list. + const isActiveIdInList = store.useState( + ( state: { items: any[]; activeId: any } ) => + state.items.some( + ( item: { id: any } ) => item.id === state.activeId + ) + ); + 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() ); + } + } + }, [ isActiveIdInList ] ); + const hasData = data?.length; if ( ! hasData ) { return ( @@ -222,11 +416,13 @@ export default function ViewList( props: ListViewProps ) { ); diff --git a/packages/dataviews/src/view-table.js b/packages/dataviews/src/view-table.js index d2b351fa80c6b4..f06b364a5c8e8d 100644 --- a/packages/dataviews/src/view-table.js +++ b/packages/dataviews/src/view-table.js @@ -362,16 +362,16 @@ function TableRow( { } function ViewTable( { - view, - onChangeView, - fields, actions, data, + fields, getItemId, isLoading = false, - selection, + onChangeView, onSelectionChange, + selection, setOpenedFilter, + view, } ) { const headerMenuRefs = useRef( new Map() ); const headerMenuToFocusRef = useRef();