Skip to content
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

Add selection to the tree control #36435

Merged
merged 16 commits into from Feb 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
@@ -0,0 +1,4 @@
Significance: minor
Type: dev

Add selection logic to tree control component
4 changes: 2 additions & 2 deletions packages/js/components/package.json
Expand Up @@ -48,8 +48,8 @@
"@wordpress/block-editor": "^9.8.0",
"@wordpress/block-library": "^7.16.0",
"@wordpress/blocks": "^11.18.0",
"@wordpress/components": "^19.5.0",
"@wordpress/compose": "^5.1.2",
"@wordpress/components": "19.8.5",
"@wordpress/compose": "5.4.1",
"@wordpress/core-data": "^4.2.1",
"@wordpress/date": "^4.3.1",
"@wordpress/deprecated": "^3.3.1",
Expand Down
Expand Up @@ -3,15 +3,15 @@
exports[`AbbreviatedCard it renders correctly 1`] = `
<div>
<div
class="components-surface components-card woocommerce-abbreviated-card css-1vyvcpq-View-Surface-getBorders-primary-Card-rounded em57xhy0"
class="components-surface components-card woocommerce-abbreviated-card css-nsno0f-View-Surface-getBorders-primary-Card-rounded em57xhy0"
data-wp-c16t="true"
data-wp-component="Card"
>
<div
class="css-mgwsf4-View-Content em57xhy0"
>
<div
class="components-card__body components-card-body css-1sfrl79-View-Body-borderRadius em57xhy0"
class="components-card__body components-card-body css-1i4jx7i-View-Body-borderRadius em57xhy0"
data-wp-c16t="true"
data-wp-component="CardBody"
>
Expand Down
@@ -0,0 +1,167 @@
/**
* External dependencies
*/
import { useMemo } from 'react';

/**
* Internal dependencies
*/
import { CheckedStatus, Item, LinkedTree, TreeItemProps } from '../types';

let selectedItemsMap: Record< string, number > = {};
let indeterminateMemo: Record< string, boolean > = {};

function getDeepChildren( item: LinkedTree ) {
if ( item.children.length ) {
const children = item.children.map( ( { data } ) => data );
item.children.forEach( ( child ) => {
children.push( ...getDeepChildren( child ) );
} );
return children;
}
return [];
}

function isIndeterminate(
selectedItems: Record< string, number >,
children?: LinkedTree[],
memo: Record< string, boolean > = indeterminateMemo
): boolean {
if ( children?.length ) {
for ( const child of children ) {
if ( child.data.value in indeterminateMemo ) {
return true;
}
const isChildSelected = child.data.value in selectedItems;
if (
! isChildSelected ||
isIndeterminate( selectedItems, child.children, memo )
) {
indeterminateMemo[ child.data.value ] = true;
return true;
}
}
}
return false;
}

function mapSelectedItems(
selected: Item | Item[] = []
): Record< string, number > {
const selectedArray = Array.isArray( selected ) ? selected : [ selected ];
return selectedArray.reduce(
( map, selectedItem, index ) => ( {
...map,
[ selectedItem.value ]: index,
} ),
{} as Record< string, number >
);
}

function hasSelectedSibblingChildren(
children: LinkedTree[],
values: Item[],
selectedItems: Record< string, number >
) {
return children.some( ( child ) => {
const isChildSelected = child.data.value in selectedItems;
if ( ! isChildSelected ) return false;
return ! values.some(
( childValue ) => childValue.value === child.data.value
);
} );
}

export function useSelection( {
item,
multiple,
selected,
level,
index,
onSelect,
onRemove,
}: Pick<
TreeItemProps,
| 'item'
| 'multiple'
| 'selected'
| 'level'
| 'index'
| 'onSelect'
| 'onRemove'
> ) {
const selectedItems = useMemo( () => {
if ( level === 1 && index === 0 ) {
selectedItemsMap = mapSelectedItems( selected );
indeterminateMemo = {} as Record< string, boolean >;
}
return selectedItemsMap;
}, [ selected, level, index ] );

const checkedStatus: CheckedStatus = useMemo( () => {
if ( item.data.value in selectedItems ) {
if ( multiple && isIndeterminate( selectedItems, item.children ) ) {
return 'indeterminate';
}
return 'checked';
}
return 'unchecked';
}, [ selectedItems, item, multiple ] );

function onSelectChild( checked: boolean ) {
let value: Item | Item[] = item.data;

if ( multiple ) {
value = [ item.data ];
if ( item.children.length ) {
value.push( ...getDeepChildren( item ) );
}
} else if ( item.children?.length ) {
return;
}

if ( checked ) {
if ( typeof onSelect === 'function' ) {
onSelect( value );
}
} else if ( typeof onRemove === 'function' ) {
onRemove( value );
}
}

function onSelectChildren( value: Item | Item[] ) {
if ( typeof onSelect !== 'function' ) return;

if ( multiple ) {
value = [ item.data, ...( value as Item[] ) ];
}

onSelect( value );
}

function onRemoveChildren( value: Item | Item[] ) {
if ( typeof onRemove !== 'function' ) return;

if ( multiple && item.children?.length ) {
const hasSelectedSibbling = hasSelectedSibblingChildren(
item.children,
value as Item[],
selectedItems
);
if ( ! hasSelectedSibbling ) {
value = [ item.data, ...( value as Item[] ) ];
}
}

onRemove( value );
}

return {
multiple,
selected,
checkedStatus,
onSelectChild,
onSelectChildren,
onRemoveChildren,
};
}
Expand Up @@ -8,12 +8,18 @@ import React from 'react';
*/
import { TreeItemProps } from '../types';
import { useExpander } from './use-expander';
import { useSelection } from './use-selection';

export function useTreeItem( {
item,
level,
multiple,
selected,
index,
getLabel,
shouldItemBeExpanded,
onSelect,
onRemove,
...props
}: TreeItemProps ) {
const nextLevel = level + 1;
Expand All @@ -23,10 +29,21 @@ export function useTreeItem( {
shouldItemBeExpanded,
} );

const selection = useSelection( {
item,
multiple,
selected,
level,
index,
onSelect,
onRemove,
} );

return {
item,
level: nextLevel,
expander,
selection,
getLabel,
treeItemProps: {
...props,
Expand All @@ -39,8 +56,12 @@ export function useTreeItem( {
treeProps: {
items: item.children,
level: nextLevel,
multiple: selection.multiple,
selected: selection.selected,
getItemLabel: getLabel,
shouldItemBeExpanded,
onSelect: selection.onSelectChildren,
onRemove: selection.onRemoveChildren,
},
};
}
Expand Up @@ -11,8 +11,12 @@ export function useTree( {
ref,
items,
level = 1,
multiple,
selected,
getItemLabel,
shouldItemBeExpanded,
onSelect,
onRemove,
...props
}: TreeProps ) {
return {
Expand All @@ -23,8 +27,12 @@ export function useTree( {
},
treeItemProps: {
level,
multiple,
selected,
getLabel: getItemLabel,
shouldItemBeExpanded,
onSelect,
onRemove,
},
};
}
Expand Up @@ -10,6 +10,7 @@ import React, { createElement, useCallback, useState } from 'react';
*/
import { TreeControl } from '../tree-control';
import { Item, LinkedTree } from '../types';
import '../tree.scss';

const listItems: Item[] = [
{ value: '1', label: 'Technology' },
Expand Down Expand Up @@ -120,25 +121,64 @@ function getItemLabel( item: LinkedTree, text: string ) {
);
}

export const CustomItemLabelOnSearch: React.FC = () => {
const [ filter, setFilter ] = useState( '' );
export const SelectionSingle: React.FC = () => {
const [ selected, setSelected ] = useState( listItems[ 1 ] );

return (
<>
<TextControl value={ filter } onChange={ setFilter } />
<BaseControl
label="Custom item label on search"
id="custom-item-label-on-search"
>
<BaseControl label="Single selection" id="single-selection">
<TreeControl
id="custom-item-label-on-search"
id="single-selection"
items={ listItems }
getItemLabel={ ( item ) => getItemLabel( item, filter ) }
shouldItemBeExpanded={ ( item ) =>
shouldItemBeExpanded( item, filter )
}
selected={ selected }
onSelect={ ( value: Item ) => setSelected( value ) }
/>
</BaseControl>

<pre>{ JSON.stringify( selected, null, 2 ) }</pre>
</>
);
};

export const SelectionMultiple: React.FC = () => {
const [ selected, setSelected ] = useState( [
listItems[ 0 ],
listItems[ 1 ],
] );

function handleSelect( values: Item[] ) {
setSelected( ( items ) => {
const newItems = values.filter(
( { value } ) =>
! items.some( ( item ) => item.value === value )
);
return [ ...items, ...newItems ];
} );
}

function handleRemove( values: Item[] ) {
setSelected( ( items ) =>
items.filter(
( item ) =>
! values.some( ( { value } ) => item.value === value )
)
);
}

return (
<>
<BaseControl label="Multiple selection" id="multiple-selection">
<TreeControl
id="multiple-selection"
items={ listItems }
multiple
selected={ selected }
onSelect={ handleSelect }
onRemove={ handleRemove }
/>
</BaseControl>

<pre>{ JSON.stringify( selected, null, 2 ) }</pre>
</>
);
};
Expand Down