Skip to content

DataViews: Add data picker component #70971

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 32 commits into
base: trunk
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
5fd0746
Create DataPicker component as a sibling to DataViews
talldan Jul 21, 2025
f6f7115
Update prop handling
talldan Jul 22, 2025
6d5e837
Add foundational parts for DataPicker component
talldan Jul 25, 2025
f1783e7
Add some initial styles a parts of the dataview UI (search, filters, …
talldan Jul 28, 2025
b1b623f
Add aria properties
talldan Jul 28, 2025
2ab695e
Refactor layout to separate component
talldan Jul 28, 2025
8ff8c83
Implement basic active descendent handling
talldan Jul 28, 2025
99313ba
Refactor active index
talldan Jul 28, 2025
1de7571
Fix Voiceover moving focus outside listbox
talldan Jul 28, 2025
df96268
Add focus ring
talldan Jul 28, 2025
08ee052
Fix incorrect active descendent when paginating
talldan Jul 28, 2025
f078310
Beginnings of selection
talldan Jul 28, 2025
8ae5450
Make the description an aria-description
talldan Jul 28, 2025
8c40af1
Add correct padding around grid
talldan Jul 29, 2025
4e6bf58
Improve selection style
talldan Jul 29, 2025
1d13052
Allow selection across pagination
talldan Jul 29, 2025
1cd771b
Match grid sizing of grid dataview
talldan Jul 29, 2025
7877ee9
Use ariakit instead of custom active descendent implementation
talldan Jul 29, 2025
0838c64
Use focus-visible for focus rings
talldan Jul 29, 2025
c7ef973
Clarify comment
talldan Jul 29, 2025
dbfdfb4
Only show focus outline when an active descendant is not set
talldan Jul 30, 2025
a72f23c
Handle multi-selection
talldan Jul 30, 2025
0df3f0b
Add aria label to listbox
talldan Jul 30, 2025
85febc1
Add controls
talldan Jul 30, 2025
0801cd4
Use aria-selected instead of aria-checked, the latter is not announce…
talldan Jul 30, 2025
e190e90
Add no results / loading indication
talldan Jul 30, 2025
291ad34
Disable finish button when no items selected
talldan Jul 30, 2025
cfb2180
Add more footer furnishings
talldan Jul 30, 2025
9ec9fdc
Rearrange footer
talldan Jul 30, 2025
20815ec
Only show select all checkbox for multiselections
talldan Jul 30, 2025
6dbe93b
Revert export of dataviews types
talldan Jul 31, 2025
9992c14
Adopt responsive image changes
talldan Jul 31, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
267 changes: 267 additions & 0 deletions packages/dataviews/src/components/datapicker/grid-layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
/**
* External dependencies
*/
import clsx from 'clsx';

/**
* WordPress dependencies
*/
import {
Composite,
__experimentalVStack as VStack,
CheckboxControl,
Spinner,
} from '@wordpress/components';
import { __ } from '@wordpress/i18n';

/**
* Internal dependencies
*/
import type {
NormalizedField,
ViewPickerGrid,
ViewBaseProps,
} from '../../types';
import type { SetSelection } from '../../private-types';
import { useContext } from '@wordpress/element';
import DataViewsContext from '../dataviews-context';

type DataPickerGridLayoutProps< Item > = {
multiple: boolean;
label: string;
data: ViewBaseProps< Item >[ 'data' ];
fields: ViewBaseProps< Item >[ 'fields' ];
getItemId: ViewBaseProps< Item >[ 'getItemId' ];
isLoading?: boolean;
onChangeView: ViewBaseProps< Item >[ 'onChangeView' ];
view: ViewPickerGrid;
selection: string[];
onChangeSelection: SetSelection;
setSize: number;
startPosition: number;
};

export default function DataPickerGridLayout< Item >( {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need to be different that "grid"? I'm trying to understand what are the main differences between the two components, what should be shared or not...?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The markup, semantics and keyboard navigation are quite different. Also different props.

The parts that are most similar are some of the styles, like the grid columns and responsiveness.

Possibly we could try using the same view, but consider selection a different mode of the view that when active adds the different roles, keyboard nav and so on. It'd need to do quite a lot, like remove all the tabbable elements within items. 🤔

Copy link
Contributor

@youknowriad youknowriad Jul 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possibly we could try using the same view, but consider selection a different mode of the view that when active adds the different roles, keyboard nav and so on. It'd need to do quite a lot, like remove all the tabbable elements within items. 🤔

Yes, this is what I kind of was thinking about, maybe a "layout" can decide whether it can be used in "DataViews", "DataPicker" or both.

Now it doesn't mean that it can't be two separate components, but I feel like the entry point should at least be the same no?

Now, I do think this PR is a bit "fundamental" in the sense that introduces a new component / new behavior entirely for DataViews package. I would love as much feedback as possible as I do have uncertainties myself. cc @WordPress/gutenberg-core @mtias @aduth

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we were to reuse layouts, why not just use DataViews with a "picker" mode instead of creating a dedicated component? Most (maybe all?) of the custom behaviour we need to introduce here concerns the layout structure, semantics and interaction, as well as the structure and interaction of the items, which is also defined inside the layouts.

For the sake of not adding more complexity to the current view layouts, I like the idea of adding a new "picker" layout - or several, but I'm not convinced picker needs more than one layout component. Certainly its HTML shouldn't change because it needs the listbox semantics, so if we want to customise the display I think it would be better to do it purely with CSS variations.

multiple,
label,
data,
fields,
getItemId,
isLoading,
view,
selection,
onChangeSelection,
setSize,
startPosition,
}: DataPickerGridLayoutProps< Item > ) {
const { resizeObserverRef } = useContext( DataViewsContext );

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 usedPreviewSize = view.layout?.previewSize;
/*
* 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';

return (
<>
{ hasData && (
<Composite
virtualFocus
orientation="horizontal"
render={
<ul
ref={
resizeObserverRef as React.RefObject< HTMLUListElement >
}
style={ {
gridTemplateColumns:
usedPreviewSize &&
`repeat(auto-fill, minmax(${ usedPreviewSize }px, 1fr))`,
} }
className="dataviews-picker-grid"
aria-label={ label }
role="listbox"
aria-multiselectable={ multiple }
tabIndex={ 0 }
aria-busy={ isLoading }
/>
}
>
{ data.map( ( item, index ) => {
const position = startPosition + index;
const itemId = getItemId( item );
const className = clsx( 'dataviews-picker-grid__card', {
'is-selected': selection.includes( itemId ),
} );
return (
<GridItem
key={ itemId }
className={ className }
multiple={ multiple }
view={ view }
selection={ selection }
onChangeSelection={ onChangeSelection }
getItemId={ getItemId }
item={ item }
titleField={ titleField }
mediaField={ mediaField }
descriptionField={ descriptionField }
setSize={ setSize }
position={ position }
config={ {
sizes: size,
} }
/>
);
} ) }
</Composite>
) }
{
// Render empty state.
! hasData && (
<div
className={ clsx( {
'dataviews-loading': isLoading,
'dataviews-no-results': ! isLoading,
} ) }
>
<p>{ isLoading ? <Spinner /> : __( 'No results' ) }</p>
</div>
)
}
</>
);
}

type GridItemProps< Item > = {
className: string;
multiple: boolean;
item: Item;
view: ViewPickerGrid;
selection: string[];
onChangeSelection: SetSelection;
getItemId: ( item: Item ) => string;
titleField: NormalizedField< Item > | undefined;
mediaField: NormalizedField< Item > | undefined;
descriptionField: NormalizedField< Item > | undefined;
setSize: number;
position: number;
config: {
sizes: string;
};
};

function GridItem< Item >( {
className,
multiple,
item,
view,
selection,
onChangeSelection,
getItemId,
titleField,
mediaField,
descriptionField,
setSize,
position,
config,
}: GridItemProps< Item > ) {
const { showTitle = true, showMedia = true, showDescription = true } = view;
const renderedMediaField =
showMedia && mediaField?.render ? (
<mediaField.render
item={ item }
field={ mediaField }
config={ config }
/>
) : null;
const renderedTitleField =
showTitle && titleField?.render ? (
<titleField.render item={ item } field={ titleField } />
) : null;
const renderedDescriptionField =
showDescription && descriptionField?.render ? (
<descriptionField.render item={ item } field={ descriptionField } />
) : null;

const itemId = getItemId( item );
const isSelected = selection.includes( getItemId( item ) );

const descriptionId = `dataviews-picker-grid-item-${ itemId }-description`;

return (
<Composite.Item
render={ ( props ) => (
<VStack
{ ...props }
children={ props.children }
as="li"
spacing={ 0 }
className={ className }
role="option"
aria-posinset={ position }
aria-setsize={ setSize }
aria-selected={ isSelected }
aria-describedby={ descriptionId }
onClick={ () => {
if ( isSelected ) {
onChangeSelection(
selection.filter(
( selectionId ) => itemId !== selectionId
)
);
return;
}

if ( multiple ) {
onChangeSelection( [ ...selection, itemId ] );
} else {
onChangeSelection( [ itemId ] );
}
} }
/>
) }
>
<div className="dataviews-picker-grid__media">
{ renderedMediaField }
</div>
<CheckboxControl
// This checkbox is decorative to ensure that there are no extra tab stops
// in the grid.
// To make that happen, it's hidden from screen readers, has a tabIndex of -1
// and has pointer-events: none in its css.
aria-hidden
tabIndex={ -1 }
__nextHasNoMarginBottom
className="dataviews-picker-grid__selection-checkbox"
checked={ isSelected }
onChange={ () => {} }
/>
<div className="dataviews-picker-grid__title-field">
{ renderedTitleField }
</div>
<div
id={ descriptionId }
className="dataviews-picker-grid__description-field"
aria-hidden
>
{ renderedDescriptionField }
</div>
</Composite.Item>
);
}
Loading
Loading