From 29469830e9b950b5782f99afb85c9823f207d5b9 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 22 Aug 2024 18:59:01 +0200 Subject: [PATCH] Migrate dataviews' list view --- packages/components/src/private-apis.ts | 2 + .../dataviews-filters/search-widget.tsx | 211 +++++++++++------- .../src/dataviews-layouts/list/index.tsx | 110 +++++---- 3 files changed, 196 insertions(+), 127 deletions(-) diff --git a/packages/components/src/private-apis.ts b/packages/components/src/private-apis.ts index 05f413955dd63c..96a2f3a5b318ed 100644 --- a/packages/components/src/private-apis.ts +++ b/packages/components/src/private-apis.ts @@ -17,6 +17,8 @@ lock( privateApis, { CompositeGroupV2: Composite.Group, CompositeItemV2: Composite.Item, CompositeRowV2: Composite.Row, + CompositeHoverV2: Composite.Hover, + CompositeTypeaheadV2: Composite.Typeahead, __experimentalPopoverLegacyPositionToPlacement, createPrivateSlotFill, ComponentsContext, diff --git a/packages/dataviews/src/components/dataviews-filters/search-widget.tsx b/packages/dataviews/src/components/dataviews-filters/search-widget.tsx index ca24de59b1ea73..6d08da0e00743b 100644 --- a/packages/dataviews/src/components/dataviews-filters/search-widget.tsx +++ b/packages/dataviews/src/components/dataviews-filters/search-widget.tsx @@ -9,7 +9,15 @@ import removeAccents from 'remove-accents'; * WordPress dependencies */ import { __, sprintf } from '@wordpress/i18n'; -import { useState, useMemo, useDeferredValue } from '@wordpress/element'; +import { + useState, + useMemo, + useDeferredValue, + useRef, + useCallback, + useEffect, + useContext, +} from '@wordpress/element'; import { VisuallyHidden, Icon, @@ -27,7 +35,8 @@ import type { Filter, NormalizedFilter, View } from '../../types'; const { CompositeV2: Composite, CompositeItemV2: CompositeItem, - useCompositeStoreV2: useCompositeStore, + CompositeHoverV2: CompositeHover, + CompositeTypeaheadV2: CompositeTypeahead, } = unlock( componentsPrivateApis ); interface SearchWidgetProps { @@ -84,22 +93,49 @@ const getNewValue = ( return [ value ]; }; +// TODO: remove Ariakit.CompositeStore types when the Composite component +// is public and includes its own types +function CompositeStoreGetter( { + children, + onStoreChange, +}: { + children: React.ReactNode; + onStoreChange: ( store?: Ariakit.CompositeStore ) => void; +} ) { + const { store: compositeStore } = + ( useContext( Composite.Context ) as + | { store: Ariakit.CompositeStore } + | undefined ) ?? {}; + useEffect( () => { + onStoreChange( compositeStore ); + }, [ onStoreChange, compositeStore ] ); + return children; +} + function ListBox( { view, filter, onChangeView }: SearchWidgetProps ) { - const compositeStore = useCompositeStore( { - virtualFocus: true, - focusLoop: true, - // When we have no or just one operator, we can set the first item as active. - // We do that by passing `undefined` to `defaultActiveId`. Otherwise, we set it to `null`, - // so the first item is not selected, since the focus is on the operators control. - defaultActiveId: filter.operators?.length === 1 ? undefined : null, - } ); + const compositeStoreRef = useRef< Ariakit.CompositeStore >(); + const onStoreChange = useCallback< + NonNullable< + React.ComponentProps< typeof CompositeStoreGetter > + >[ 'onStoreChange' ] + >( ( store ) => { + compositeStoreRef.current = store; + }, [] ); + const currentFilter = view.filters?.find( ( f ) => f.field === filter.field ); const currentValue = getCurrentValue( filter, currentFilter ); return ( { + // `onFocusVisible` needs the `Composite` component to be focusable, + // which is implicitly achieved via the `virtualFocus: true` option + // in the `useCompositeStore` hook. + const compositeStore = compositeStoreRef.current; + if ( ! compositeStore ) { + return; + } if ( ! compositeStore.getState().activeId ) { compositeStore.move( compositeStore.first() ); } } } - render={ } + render={ } > - { filter.elements.map( ( element ) => ( - - } - onClick={ () => { - const newFilters = currentFilter - ? [ - ...( view.filters ?? [] ).map( - ( _filter ) => { - if ( - _filter.field === - filter.field - ) { - return { - ..._filter, - operator: - currentFilter.operator || - filter - .operators[ 0 ], - value: getNewValue( - filter, - currentFilter, - element.value - ), - }; + + { filter.elements.map( ( element ) => ( + + } + onClick={ () => { + const newFilters = currentFilter + ? [ + ...( view.filters ?? [] ).map( + ( _filter ) => { + if ( + _filter.field === + filter.field + ) { + return { + ..._filter, + operator: + currentFilter.operator || + filter + .operators[ 0 ], + value: getNewValue( + filter, + currentFilter, + element.value + ), + }; + } + return _filter; } - return _filter; - } - ), - ] - : [ - ...( view.filters ?? [] ), - { - field: filter.field, - operator: filter.operators[ 0 ], - value: getNewValue( - filter, - currentFilter, - element.value ), - }, - ]; - onChangeView( { - ...view, - page: 1, - filters: newFilters, - } ); - } } - /> - } - > - - { filter.singleSelection && - currentValue === element.value && ( - - ) } - { ! filter.singleSelection && - currentValue.includes( element.value ) && ( - - ) } - - { element.label } - - ) ) } + ] + : [ + ...( view.filters ?? [] ), + { + field: filter.field, + operator: + filter.operators[ 0 ], + value: getNewValue( + filter, + currentFilter, + element.value + ), + }, + ]; + onChangeView( { + ...view, + page: 1, + filters: newFilters, + } ); + } } + /> + } + > + + { filter.singleSelection && + currentValue === element.value && ( + + ) } + { ! filter.singleSelection && + currentValue.includes( element.value ) && ( + + ) } + + { element.label } + + ) ) } + ); } diff --git a/packages/dataviews/src/dataviews-layouts/list/index.tsx b/packages/dataviews/src/dataviews-layouts/list/index.tsx index d8889f25b24e9c..b84c7cc751a8f4 100644 --- a/packages/dataviews/src/dataviews-layouts/list/index.tsx +++ b/packages/dataviews/src/dataviews-layouts/list/index.tsx @@ -4,10 +4,7 @@ import clsx from 'clsx'; // TODO: use the @wordpress/components one once public // eslint-disable-next-line no-restricted-imports -import { useStoreState } from '@ariakit/react'; -// Import CompositeStore type, which is not exported from @wordpress/components. -// eslint-disable-next-line no-restricted-imports -import type { CompositeStore } from '@ariakit/react'; +import * as Ariakit from '@ariakit/react'; /** * WordPress dependencies @@ -27,6 +24,7 @@ import { useMemo, useRef, useState, + useContext, } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { moreVertical } from '@wordpress/icons'; @@ -50,12 +48,11 @@ interface ListViewItemProps< Item > { mediaField?: NormalizedField< Item >; onSelect: ( item: Item ) => void; primaryField?: NormalizedField< Item >; - store: CompositeStore; + store?: Ariakit.CompositeStore; visibleFields: NormalizedField< Item >[]; } const { - useCompositeStoreV2: useCompositeStore, CompositeV2: Composite, CompositeItemV2: CompositeItem, CompositeRowV2: CompositeRow, @@ -147,7 +144,6 @@ function ListItem< Item >( { >
} role="button" id={ id } @@ -213,7 +209,6 @@ function ListItem< Item >( { { primaryAction && 'RenderModal' in primaryAction && (
( { ! ( 'RenderModal' in primaryAction ) && (
( { ( { label={ __( 'Actions' ) } accessibleWhenDisabled disabled={ ! actions.length } - onKeyDown={ ( event: { - key: string; - preventDefault: () => void; - } ) => { + // Prevent the default behavior (open dropdown menu) + // and instead move the composite item selection. + // https://github.com/ariakit/ariakit/issues/3768 + onKeyDown={ ( + event: React.KeyboardEvent< HTMLButtonElement > + ) => { + if ( ! store ) { + return; + } + 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() @@ -319,6 +317,25 @@ function ListItem< Item >( { ); } +// TODO: remove Ariakit.CompositeStore types when the Composite component +// is public and includes its own types +function CompositeStoreGetter( { + children, + onStoreChange, +}: { + children: React.ReactNode; + onStoreChange: ( store?: Ariakit.CompositeStore ) => void; +} ) { + const { store: compositeStore } = + ( useContext( Composite.Context ) as + | { store: Ariakit.CompositeStore } + | undefined ) ?? {}; + useEffect( () => { + onStoreChange( compositeStore ); + }, [ onStoreChange, compositeStore ] ); + return children; +} + export default function ViewList< Item >( props: ViewListProps< Item > ) { const { actions, @@ -359,20 +376,23 @@ export default function ViewList< Item >( props: ViewListProps< Item > ) { [ baseId, getItemId ] ); - const store = useCompositeStore( { - defaultActiveId: getItemDomId( selectedItem ), - } ) as CompositeStore; // TODO, remove once composite APIs are public + const compositeStoreRef = useRef< Ariakit.CompositeStore >(); + const onStoreChange = useCallback< + NonNullable< + React.ComponentProps< typeof CompositeStoreGetter > + >[ 'onStoreChange' ] + >( ( store ) => { + compositeStoreRef.current = store; + }, [] ); // Manage focused item, when the active one is removed from the list. - const isActiveIdInList = useStoreState( - store, - ( state: { items: any[]; activeId: any } ) => - state.items.some( - ( item: { id: any } ) => item.id === state.activeId - ) + const isActiveIdInList = Ariakit.useStoreState( + compositeStoreRef.current, + ( state ) => state?.items.some( ( item ) => item.id === state.activeId ) ); useEffect( () => { - if ( ! isActiveIdInList ) { + const store = compositeStoreRef.current; + if ( store && ! 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() ); @@ -404,25 +424,27 @@ export default function ViewList< Item >( props: ViewListProps< Item > ) { render={
    } className="dataviews-view-list" role="grid" - store={ store } + defaultActiveId={ getItemDomId( selectedItem ) } > - { data.map( ( item ) => { - const id = getItemDomId( item ); - return ( - - ); - } ) } + + { data.map( ( item ) => { + const id = getItemDomId( item ); + return ( + + ); + } ) } + ); }