diff --git a/packages/js/components/changelog/update-navigation-while-on-input b/packages/js/components/changelog/update-navigation-while-on-input
new file mode 100644
index 0000000000000..169cc6b8f5e30
--- /dev/null
+++ b/packages/js/components/changelog/update-navigation-while-on-input
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Update SelectTree and Tree controls to allow highlighting items without focus
diff --git a/packages/js/components/src/experimental-select-control/selected-items.tsx b/packages/js/components/src/experimental-select-control/selected-items.tsx
index 99056a6d2ca76..acdff11f0bed6 100644
--- a/packages/js/components/src/experimental-select-control/selected-items.tsx
+++ b/packages/js/components/src/experimental-select-control/selected-items.tsx
@@ -77,6 +77,25 @@ const PrivateSelectedItems = < ItemType, >(
);
}
+ const focusSibling = ( event: React.KeyboardEvent< HTMLDivElement > ) => {
+ const selectedItem = ( event.target as HTMLElement ).closest(
+ '.woocommerce-experimental-select-control__selected-item'
+ );
+ const sibling =
+ event.key === 'ArrowLeft' || event.key === 'Backspace'
+ ? selectedItem?.previousSibling
+ : selectedItem?.nextSibling;
+ if ( sibling ) {
+ (
+ ( sibling as HTMLElement ).querySelector(
+ '.woocommerce-tag__remove'
+ ) as HTMLElement
+ )?.focus();
+ return true;
+ }
+ return false;
+ };
+
return (
{ items.map( ( item, index ) => {
@@ -102,24 +121,9 @@ const PrivateSelectedItems = < ItemType, >(
event.key === 'ArrowLeft' ||
event.key === 'ArrowRight'
) {
- const selectedItem = (
- event.target as HTMLElement
- ).closest(
- '.woocommerce-experimental-select-control__selected-item'
- );
- const sibling =
- event.key === 'ArrowLeft'
- ? selectedItem?.previousSibling
- : selectedItem?.nextSibling;
- if ( sibling ) {
- (
- (
- sibling as HTMLElement
- ).querySelector(
- '.woocommerce-tag__remove'
- ) as HTMLElement
- )?.focus();
- } else if (
+ const focused = focusSibling( event );
+ if (
+ ! focused &&
event.key === 'ArrowRight' &&
onSelectedItemsEnd
) {
@@ -130,6 +134,9 @@ const PrivateSelectedItems = < ItemType, >(
event.key === 'ArrowDown'
) {
event.preventDefault(); // prevent unwanted scroll
+ } else if ( event.key === 'Backspace' ) {
+ onRemove( item );
+ focusSibling( event );
}
} }
onBlur={ onBlur }
diff --git a/packages/js/components/src/experimental-select-tree-control/select-tree-menu.tsx b/packages/js/components/src/experimental-select-tree-control/select-tree-menu.tsx
index 1b7267389b4ce..b1c9dd4d1ba5c 100644
--- a/packages/js/components/src/experimental-select-tree-control/select-tree-menu.tsx
+++ b/packages/js/components/src/experimental-select-tree-control/select-tree-menu.tsx
@@ -10,6 +10,7 @@ import {
useLayoutEffect,
useState,
} from '@wordpress/element';
+import { escapeRegExp } from 'lodash';
/**
* Internal dependencies
@@ -26,6 +27,7 @@ type MenuProps = {
isLoading?: boolean;
position?: Popover.Position;
scrollIntoViewOnOpen?: boolean;
+ highlightedIndex?: number;
items: LinkedTree[];
treeRef?: React.ForwardedRef< HTMLOListElement >;
onClose?: () => void;
@@ -44,6 +46,7 @@ export const SelectTreeMenu = ( {
onEscape,
shouldShowCreateButton,
onFirstItemLoop,
+ onExpand,
...props
}: MenuProps ) => {
const [ boundingRect, setBoundingRect ] = useState< DOMRect >();
@@ -66,7 +69,7 @@ export const SelectTreeMenu = ( {
// Scroll the selected item into view when the menu opens.
useEffect( () => {
if ( isOpen && scrollIntoViewOnOpen ) {
- selectControlMenuRef.current?.scrollIntoView();
+ selectControlMenuRef.current?.scrollIntoView?.();
}
}, [ isOpen, scrollIntoViewOnOpen ] );
@@ -74,9 +77,10 @@ export const SelectTreeMenu = ( {
if ( ! props.createValue || ! item.children?.length ) return false;
return item.children.some( ( child ) => {
if (
- new RegExp( props.createValue || '', 'ig' ).test(
- child.data.label
- )
+ new RegExp(
+ escapeRegExp( props.createValue || '' ),
+ 'ig'
+ ).test( child.data.label )
) {
return true;
}
@@ -130,6 +134,7 @@ export const SelectTreeMenu = ( {
ref={ ref }
items={ items }
onTreeBlur={ onClose }
+ onExpand={ onExpand }
shouldItemBeExpanded={
shouldItemBeExpanded
}
diff --git a/packages/js/components/src/experimental-select-tree-control/select-tree.tsx b/packages/js/components/src/experimental-select-tree-control/select-tree.tsx
index bb08478484afe..ed5d7b09b0641 100644
--- a/packages/js/components/src/experimental-select-tree-control/select-tree.tsx
+++ b/packages/js/components/src/experimental-select-tree-control/select-tree.tsx
@@ -19,8 +19,17 @@ import { speak } from '@wordpress/a11y';
/**
* Internal dependencies
*/
-import { useLinkedTree } from '../experimental-tree-control/hooks/use-linked-tree';
-import { Item, TreeControlProps } from '../experimental-tree-control/types';
+import {
+ toggleNode,
+ createLinkedTree,
+ getVisibleNodeIndex as getVisibleNodeIndex,
+ getNodeDataByIndex,
+} from '../experimental-tree-control/linked-tree-utils';
+import {
+ Item,
+ LinkedTree,
+ TreeControlProps,
+} from '../experimental-tree-control/types';
import { SelectedItems } from '../experimental-select-control/selected-items';
import { ComboBox } from '../experimental-select-control/combo-box';
import { SuffixIcon } from '../experimental-select-control/suffix-icon';
@@ -55,7 +64,17 @@ export const SelectTree = function SelectTree( {
onClear = () => {},
...props
}: SelectTreeProps ) {
- const linkedTree = useLinkedTree( items );
+ const [ linkedTree, setLinkedTree ] = useState< LinkedTree[] >( [] );
+ const [ highlightedIndex, setHighlightedIndex ] = useState( -1 );
+
+ // whenever the items change, the linked tree needs to be recalculated
+ useEffect( () => {
+ setLinkedTree( createLinkedTree( items, props.createValue ) );
+ }, [ items.length ] );
+
+ // reset highlighted index when the input value changes
+ useEffect( () => setHighlightedIndex( -1 ), [ props.createValue ] );
+
const selectTreeInstanceId = useInstanceId(
SelectTree,
'woocommerce-experimental-select-tree-control__dropdown'
@@ -111,6 +130,19 @@ export const SelectTree = function SelectTree( {
}
}, [ isFocused ] );
+ // Scroll the newly highlighted item into view
+ useEffect(
+ () =>
+ document
+ .querySelector(
+ '.experimental-woocommerce-tree-item--highlighted'
+ )
+ ?.scrollIntoView?.( {
+ block: 'nearest',
+ } ),
+ [ highlightedIndex ]
+ );
+
let placeholder: string | undefined = '';
if ( Array.isArray( props.selected ) ) {
placeholder = props.selected.length === 0 ? props.placeholder : '';
@@ -118,12 +150,30 @@ export const SelectTree = function SelectTree( {
placeholder = props.placeholder;
}
+ // reset highlighted index when the input value changes
+ useEffect( () => {
+ if (
+ highlightedIndex === items.length &&
+ ! shouldShowCreateButton?.( props.createValue )
+ ) {
+ setHighlightedIndex( items.length - 1 );
+ }
+ }, [ props.createValue ] );
+
const inputProps: React.InputHTMLAttributes< HTMLInputElement > = {
className: 'woocommerce-experimental-select-control__input',
id: `${ props.id }-input`,
'aria-autocomplete': 'list',
- 'aria-controls': `${ props.id }-menu`,
+ 'aria-activedescendant':
+ highlightedIndex >= 0
+ ? `woocommerce-experimental-tree-control__menu-item-${ highlightedIndex }`
+ : undefined,
+ 'aria-controls': menuInstanceId,
+ 'aria-owns': menuInstanceId,
+ role: 'combobox',
autoComplete: 'off',
+ 'aria-expanded': isOpen,
+ 'aria-haspopup': 'tree',
disabled,
onFocus: ( event ) => {
if ( props.multiple ) {
@@ -159,40 +209,121 @@ export const SelectTree = function SelectTree( {
setIsOpen( true );
if ( event.key === 'ArrowDown' ) {
event.preventDefault();
- // focus on the first element from the Popover
- (
- document.querySelector(
- `#${ menuInstanceId } input, #${ menuInstanceId } button`
- ) as HTMLInputElement | HTMLButtonElement
- )?.focus();
+ if (
+ // is advancing from the last menu item to the create button
+ highlightedIndex === items.length - 1 &&
+ shouldShowCreateButton?.( props.createValue )
+ ) {
+ setHighlightedIndex( items.length );
+ } else {
+ const visibleNodeIndex = getVisibleNodeIndex(
+ linkedTree,
+ Math.min( highlightedIndex + 1, items.length ),
+ 'down'
+ );
+ if ( visibleNodeIndex !== undefined ) {
+ setHighlightedIndex( visibleNodeIndex );
+ }
+ }
+ } else if ( event.key === 'ArrowUp' ) {
+ event.preventDefault();
+ if ( highlightedIndex > 0 ) {
+ const visibleNodeIndex = getVisibleNodeIndex(
+ linkedTree,
+ Math.max( highlightedIndex - 1, -1 ),
+ 'up'
+ );
+ if ( visibleNodeIndex !== undefined ) {
+ setHighlightedIndex( visibleNodeIndex );
+ }
+ } else {
+ setHighlightedIndex( -1 );
+ }
} else if ( event.key === 'Tab' || event.key === 'Escape' ) {
setIsOpen( false );
recalculateInputValue();
- } else if ( event.key === ',' || event.key === 'Enter' ) {
+ } else if ( event.key === 'Enter' || event.key === ',' ) {
event.preventDefault();
- const item = items.find(
- ( i ) => i.label === escapeHTML( inputValue )
- );
- const isAlreadySelected =
- Array.isArray( props.selected ) &&
- Boolean(
- props.selected.find(
- ( i ) => i.label === escapeHTML( inputValue )
- )
+ if (
+ highlightedIndex === items.length &&
+ shouldShowCreateButton
+ ) {
+ props.onCreateNew?.();
+ } else if (
+ // is selecting an item
+ highlightedIndex !== -1
+ ) {
+ const nodeData = getNodeDataByIndex(
+ linkedTree,
+ highlightedIndex
+ );
+ if ( ! nodeData ) {
+ return;
+ }
+ if ( props.multiple && Array.isArray( props.selected ) ) {
+ if (
+ ! Boolean(
+ props.selected.find(
+ ( i ) => i.label === nodeData.label
+ )
+ )
+ ) {
+ if ( props.onSelect ) {
+ props.onSelect( nodeData );
+ }
+ } else if ( props.onRemove ) {
+ props.onRemove( nodeData );
+ }
+ setInputValue( '' );
+ } else {
+ onInputChange?.( nodeData.label );
+ props.onSelect?.( nodeData );
+ setIsOpen( false );
+ setIsFocused( false );
+ focusOnInput();
+ }
+ } else if ( inputValue ) {
+ // no highlighted item, but there is an input value, check if it matches any item
+
+ const item = items.find(
+ ( i ) => i.label === escapeHTML( inputValue )
);
- if ( props.onSelect && item && ! isAlreadySelected ) {
- props.onSelect( item );
- setInputValue( '' );
- recalculateInputValue();
+ const isAlreadySelected = Array.isArray( props.selected )
+ ? Boolean(
+ props.selected.find(
+ ( i ) =>
+ i.label === escapeHTML( inputValue )
+ )
+ )
+ : props.selected?.label === escapeHTML( inputValue );
+ if ( item && ! isAlreadySelected ) {
+ props.onSelect?.( item );
+ setInputValue( '' );
+ recalculateInputValue();
+ }
}
} else if (
- ( event.key === 'ArrowLeft' || event.key === 'Backspace' ) &&
+ event.key === 'Backspace' &&
// test if the cursor is at the beginning of the input with nothing selected
( event.target as HTMLInputElement ).selectionStart === 0 &&
( event.target as HTMLInputElement ).selectionEnd === 0 &&
selectedItemsFocusHandle.current
) {
selectedItemsFocusHandle.current();
+ } else if ( event.key === 'ArrowRight' ) {
+ setLinkedTree(
+ toggleNode( linkedTree, highlightedIndex, true )
+ );
+ } else if ( event.key === 'ArrowLeft' ) {
+ setLinkedTree(
+ toggleNode( linkedTree, highlightedIndex, false )
+ );
+ } else if ( event.key === 'Home' ) {
+ event.preventDefault();
+ setHighlightedIndex( 0 );
+ } else if ( event.key === 'End' ) {
+ event.preventDefault();
+ setHighlightedIndex( items.length - 1 );
}
},
onChange: ( event ) => {
@@ -248,10 +379,6 @@ export const SelectTree = function SelectTree( {
comboBoxProps={ {
className:
'woocommerce-experimental-select-control__combo-box-wrapper',
- role: 'combobox',
- 'aria-expanded': isOpen,
- 'aria-haspopup': 'tree',
- 'aria-owns': `${ props.id }-menu`,
} }
inputProps={ inputProps }
suffix={
@@ -281,7 +408,11 @@ export const SelectTree = function SelectTree( {
item?.label || ''
}
@@ -290,6 +421,7 @@ export const SelectTree = function SelectTree( {
}
onRemove={ ( item ) => {
if (
+ item &&
! Array.isArray( item ) &&
props.onRemove
) {
@@ -346,6 +478,12 @@ export const SelectTree = function SelectTree( {
isEventOutside={ isEventOutside }
isLoading={ isLoading }
isOpen={ isOpen }
+ highlightedIndex={ highlightedIndex }
+ onExpand={ ( index, value ) => {
+ setLinkedTree(
+ toggleNode( linkedTree, index, value )
+ );
+ } }
items={ linkedTree }
shouldShowCreateButton={ shouldShowCreateButton }
onEscape={ () => {
diff --git a/packages/js/components/src/experimental-select-tree-control/test/select-tree.test.tsx b/packages/js/components/src/experimental-select-tree-control/test/select-tree.test.tsx
index b6f9dd266fbb9..07a8694897ed9 100644
--- a/packages/js/components/src/experimental-select-tree-control/test/select-tree.test.tsx
+++ b/packages/js/components/src/experimental-select-tree-control/test/select-tree.test.tsx
@@ -1,4 +1,6 @@
import { render } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { useState } from 'react';
import React, { createElement } from '@wordpress/element';
import { SelectTree } from '../select-tree';
import { Item } from '../../experimental-tree-control';
@@ -26,6 +28,44 @@ const DEFAULT_PROPS = {
placeholder: 'Type here',
};
+const TestComponent = ( { multiple }: { multiple?: boolean } ) => {
+ const [ typedValue, setTypedValue ] = useState( '' );
+ const [ selected, setSelected ] = useState< any >( [] );
+
+ return createElement( SelectTree, {
+ ...DEFAULT_PROPS,
+ multiple,
+ shouldShowCreateButton: () => true,
+ onInputChange: ( value ) => {
+ setTypedValue( value || '' );
+ },
+ createValue: typedValue,
+ selected: Array.isArray( selected )
+ ? selected.map( ( i ) => ( {
+ value: String( i.id ),
+ label: i.name,
+ } ) )
+ : {
+ value: String( selected.id ),
+ label: selected.name,
+ },
+ onSelect: ( item: Item | Item[] ) =>
+ item && Array.isArray( item )
+ ? setSelected(
+ item.map( ( i ) => ( {
+ id: +i.value,
+ name: i.label,
+ parent: i.parent ? +i.parent : 0,
+ } ) )
+ )
+ : setSelected( {
+ id: +item.value,
+ name: item.label,
+ parent: item.parent ? +item.parent : 0,
+ } ),
+ } );
+};
+
describe( 'SelectTree', () => {
beforeEach( () => {
jest.clearAllMocks();
@@ -36,7 +76,7 @@ describe( 'SelectTree', () => {
);
expect( queryByText( 'Item 1' ) ).not.toBeInTheDocument();
- queryByRole( 'textbox' )?.focus();
+ queryByRole( 'combobox' )?.focus();
expect( queryByText( 'Item 1' ) ).toBeInTheDocument();
} );
@@ -47,20 +87,21 @@ describe( 'SelectTree', () => {
shouldShowCreateButton={ () => true }
/>
);
- queryByRole( 'textbox' )?.focus();
+ queryByRole( 'combobox' )?.focus();
expect( queryByText( 'Create new' ) ).toBeInTheDocument();
} );
it( 'should not show create button when callback is false or no callback', () => {
const { queryByText, queryByRole } = render(
);
- queryByRole( 'textbox' )?.focus();
+ queryByRole( 'combobox' )?.focus();
expect( queryByText( 'Create new' ) ).not.toBeInTheDocument();
} );
it( 'should show a root item when focused and child when expand button is clicked', () => {
- const { queryByText, queryByLabelText, queryByRole } =
- render( );
- queryByRole( 'textbox' )?.focus();
+ const { queryByText, queryByLabelText, queryByRole } = render(
+
+ );
+ queryByRole( 'combobox' )?.focus();
expect( queryByText( 'Item 1' ) ).toBeInTheDocument();
expect( queryByText( 'Item 2' ) ).not.toBeInTheDocument();
@@ -72,7 +113,7 @@ describe( 'SelectTree', () => {
const { queryAllByRole, queryByRole } = render(
);
- queryByRole( 'textbox' )?.focus();
+ queryByRole( 'combobox' )?.focus();
expect( queryAllByRole( 'treeitem' )[ 0 ] ).toHaveAttribute(
'aria-selected',
'true'
@@ -87,7 +128,7 @@ describe( 'SelectTree', () => {
shouldShowCreateButton={ () => true }
/>
);
- queryByRole( 'textbox' )?.focus();
+ queryByRole( 'combobox' )?.focus();
expect( queryByText( 'Create "new item"' ) ).toBeInTheDocument();
} );
it( 'should call onCreateNew when Create "" button is clicked', () => {
@@ -100,8 +141,34 @@ describe( 'SelectTree', () => {
onCreateNew={ mockFn }
/>
);
- queryByRole( 'textbox' )?.focus();
+ queryByRole( 'combobox' )?.focus();
queryByText( 'Create "new item"' )?.click();
expect( mockFn ).toBeCalledTimes( 1 );
} );
+ it( 'correctly selects existing item in single mode with arrow keys', async () => {
+ const { findByRole } = render( );
+ const combobox = ( await findByRole( 'combobox' ) ) as HTMLInputElement;
+ combobox.focus();
+ userEvent.keyboard( '{arrowdown}{enter}' );
+ expect( combobox.value ).toBe( 'Item 1' );
+ } );
+ it( 'correctly selects existing item in single mode by typing and pressing Enter', async () => {
+ const { findByRole } = render( );
+ const combobox = ( await findByRole( 'combobox' ) ) as HTMLInputElement;
+ combobox.focus();
+ userEvent.keyboard( 'Item 1{enter}' );
+ userEvent.tab();
+ expect( combobox.value ).toBe( 'Item 1' );
+ } );
+ it( 'correctly selects existing item in multiple mode by typing and pressing Enter', async () => {
+ const { findByRole, getAllByText } = render(
+
+ );
+ const combobox = ( await findByRole( 'combobox' ) ) as HTMLInputElement;
+ combobox.focus();
+ userEvent.keyboard( 'Item 1' );
+ userEvent.keyboard( '{enter}' );
+ expect( combobox.value ).toBe( '' ); // input is cleared
+ expect( getAllByText( 'Item 1' )[ 0 ] ).toBeInTheDocument(); // item is selected (turns into a token)
+ } );
} );
diff --git a/packages/js/components/src/experimental-tree-control/hooks/use-linked-tree.ts b/packages/js/components/src/experimental-tree-control/hooks/use-linked-tree.ts
deleted file mode 100644
index 94ff95706b8f3..0000000000000
--- a/packages/js/components/src/experimental-tree-control/hooks/use-linked-tree.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-/**
- * External dependencies
- */
-import { useMemo } from 'react';
-
-/**
- * Internal dependencies
- */
-import { Item, LinkedTree } from '../types';
-
-type MemoItems = {
- [ value: Item[ 'value' ] ]: LinkedTree;
-};
-
-function findChildren(
- items: Item[],
- parent?: Item[ 'parent' ],
- memo: MemoItems = {}
-): LinkedTree[] {
- const children: Item[] = [];
- const others: Item[] = [];
-
- items.forEach( ( item ) => {
- if ( item.parent === parent ) {
- children.push( item );
- } else {
- others.push( item );
- }
- memo[ item.value ] = {
- parent: undefined,
- data: item,
- children: [],
- };
- } );
-
- return children.map( ( child ) => {
- const linkedTree = memo[ child.value ];
- linkedTree.parent = child.parent ? memo[ child.parent ] : undefined;
- linkedTree.children = findChildren( others, child.value, memo );
- return linkedTree;
- } );
-}
-
-export function useLinkedTree( items: Item[] ): LinkedTree[] {
- const linkedTree = useMemo( () => {
- return findChildren( items, undefined, {} );
- }, [ items ] );
-
- return linkedTree;
-}
diff --git a/packages/js/components/src/experimental-tree-control/hooks/use-tree-item.ts b/packages/js/components/src/experimental-tree-control/hooks/use-tree-item.ts
index 2a4e7dca5eeb6..3c1dba15f0470 100644
--- a/packages/js/components/src/experimental-tree-control/hooks/use-tree-item.ts
+++ b/packages/js/components/src/experimental-tree-control/hooks/use-tree-item.ts
@@ -32,6 +32,9 @@ export function useTreeItem( {
onFirstItemLoop,
onTreeBlur,
onEscape,
+ highlightedIndex,
+ isHighlighted,
+ onExpand,
...props
}: TreeItemProps ) {
const nextLevel = level + 1;
@@ -79,16 +82,19 @@ export function useTreeItem( {
getLabel,
treeItemProps: {
...props,
- role: 'none',
+ id:
+ 'woocommerce-experimental-tree-control__menu-item-' +
+ item.index,
+ role: 'option',
},
headingProps: {
role: 'treeitem',
'aria-selected': selection.checkedStatus !== 'unchecked',
'aria-expanded': item.children.length
- ? expander.isExpanded
+ ? item.data.isExpanded
: undefined,
'aria-owns':
- item.children.length && expander.isExpanded
+ item.children.length && item.data.isExpanded
? subTreeId
: undefined,
style: {
diff --git a/packages/js/components/src/experimental-tree-control/hooks/use-tree.ts b/packages/js/components/src/experimental-tree-control/hooks/use-tree.ts
index 6f6c0d2c87852..fc7c697ade53c 100644
--- a/packages/js/components/src/experimental-tree-control/hooks/use-tree.ts
+++ b/packages/js/components/src/experimental-tree-control/hooks/use-tree.ts
@@ -10,7 +10,7 @@ import { TreeProps } from '../types';
export function useTree( {
items,
level = 1,
- role = 'tree',
+ role = 'listbox',
multiple,
selected,
getItemLabel,
@@ -25,6 +25,8 @@ export function useTree( {
shouldShowCreateButton,
onFirstItemLoop,
onEscape,
+ highlightedIndex,
+ onExpand,
...props
}: TreeProps ) {
return {
diff --git a/packages/js/components/src/experimental-tree-control/linked-tree-utils.ts b/packages/js/components/src/experimental-tree-control/linked-tree-utils.ts
new file mode 100644
index 0000000000000..38f33003411ed
--- /dev/null
+++ b/packages/js/components/src/experimental-tree-control/linked-tree-utils.ts
@@ -0,0 +1,211 @@
+/**
+ * Internal dependencies
+ */
+import { AugmentedItem, Item, LinkedTree } from './types';
+
+type MemoItems = {
+ [ value: AugmentedItem[ 'value' ] ]: LinkedTree;
+};
+
+const shouldItemBeExpanded = (
+ item: LinkedTree,
+ createValue: string | undefined
+): boolean => {
+ if ( ! createValue || ! item.children?.length ) return false;
+ return item.children.some( ( child ) => {
+ if ( new RegExp( createValue || '', 'ig' ).test( child.data.label ) ) {
+ return true;
+ }
+ return shouldItemBeExpanded( child, createValue );
+ } );
+};
+
+function findChildren(
+ items: AugmentedItem[],
+ memo: MemoItems = {},
+ parent?: AugmentedItem[ 'parent' ],
+ createValue?: string | undefined
+): LinkedTree[] {
+ const children: AugmentedItem[] = [];
+ const others: AugmentedItem[] = [];
+
+ items.forEach( ( item ) => {
+ if ( item.parent === parent ) {
+ children.push( item );
+ } else {
+ others.push( item );
+ }
+ memo[ item.value ] = {
+ parent: undefined,
+ data: item,
+ children: [],
+ };
+ } );
+
+ return children.map( ( child ) => {
+ const linkedTree = memo[ child.value ];
+ linkedTree.parent = child.parent ? memo[ child.parent ] : undefined;
+ linkedTree.children = findChildren(
+ others,
+ memo,
+ child.value,
+ createValue
+ );
+ linkedTree.data.isExpanded =
+ linkedTree.children.length === 0
+ ? true
+ : shouldItemBeExpanded( linkedTree, createValue );
+ return linkedTree;
+ } );
+}
+
+function populateIndexes(
+ linkedTree: LinkedTree[],
+ startCount = 0
+): LinkedTree[] {
+ let count = startCount;
+
+ function populate( tree: LinkedTree[] ): number {
+ for ( const node of tree ) {
+ node.index = count;
+ count++;
+ if ( node.children ) {
+ count = populate( node.children );
+ }
+ }
+ return count;
+ }
+
+ populate( linkedTree );
+ return linkedTree;
+}
+
+// creates a linked tree from an array of Items
+export function createLinkedTree(
+ items: Item[],
+ value: string | undefined
+): LinkedTree[] {
+ const augmentedItems = items.map( ( i ) => ( {
+ ...i,
+ isExpanded: false,
+ } ) );
+ return populateIndexes(
+ findChildren( augmentedItems, {}, undefined, value )
+ );
+}
+
+// Toggles the expanded state of a node in a linked tree
+export function toggleNode(
+ tree: LinkedTree[],
+ number: number,
+ value: boolean
+): LinkedTree[] {
+ return tree.map( ( node ) => {
+ return {
+ ...node,
+ children: node.children
+ ? toggleNode( node.children, number, value )
+ : node.children,
+ data: {
+ ...node.data,
+ isExpanded:
+ node.index === number ? value : node.data.isExpanded,
+ },
+ ...( node.parent
+ ? {
+ parent: {
+ ...node.parent,
+ data: {
+ ...node.parent.data,
+ isExpanded:
+ node.parent.index === number
+ ? value
+ : node.parent.data.isExpanded,
+ },
+ },
+ }
+ : {} ),
+ };
+ } );
+}
+
+// Gets the index of the next/previous visible node in the linked tree
+export function getVisibleNodeIndex(
+ tree: LinkedTree[],
+ highlightedIndex: number,
+ direction: 'up' | 'down'
+): number | undefined {
+ if ( direction === 'down' ) {
+ for ( const node of tree ) {
+ if ( ! node.parent || node.parent.data.isExpanded ) {
+ if (
+ node.index !== undefined &&
+ node.index >= highlightedIndex
+ ) {
+ return node.index;
+ }
+ const visibleNodeIndex = getVisibleNodeIndex(
+ node.children,
+ highlightedIndex,
+ direction
+ );
+ if ( visibleNodeIndex !== undefined ) {
+ return visibleNodeIndex;
+ }
+ }
+ }
+ } else {
+ for ( let i = tree.length - 1; i >= 0; i-- ) {
+ const node = tree[ i ];
+ if ( ! node.parent || node.parent.data.isExpanded ) {
+ const visibleNodeIndex = getVisibleNodeIndex(
+ node.children,
+ highlightedIndex,
+ direction
+ );
+ if ( visibleNodeIndex !== undefined ) {
+ return visibleNodeIndex;
+ }
+ if (
+ node.index !== undefined &&
+ node.index <= highlightedIndex
+ ) {
+ return node.index;
+ }
+ }
+ }
+ }
+
+ return undefined;
+}
+
+// Counts the number of nodes in a LinkedTree
+export function countNumberOfNodes( linkedTree: LinkedTree[] ) {
+ let count = 0;
+ for ( const node of linkedTree ) {
+ count++;
+ if ( node.children ) {
+ count += countNumberOfNodes( node.children );
+ }
+ }
+ return count;
+}
+
+// Gets the data of a node by its index
+export function getNodeDataByIndex(
+ linkedTree: LinkedTree[],
+ index: number
+): Item | undefined {
+ for ( const node of linkedTree ) {
+ if ( node.index === index ) {
+ return node.data;
+ }
+ if ( node.children ) {
+ const child = getNodeDataByIndex( node.children, index );
+ if ( child ) {
+ return child;
+ }
+ }
+ }
+ return undefined;
+}
diff --git a/packages/js/components/src/experimental-tree-control/tree-control.tsx b/packages/js/components/src/experimental-tree-control/tree-control.tsx
index 24a484a2995c3..d2e3db9db8e6c 100644
--- a/packages/js/components/src/experimental-tree-control/tree-control.tsx
+++ b/packages/js/components/src/experimental-tree-control/tree-control.tsx
@@ -6,7 +6,7 @@ import { createElement, forwardRef } from 'react';
/**
* Internal dependencies
*/
-import { useLinkedTree } from './hooks/use-linked-tree';
+import { createLinkedTree } from './linked-tree-utils';
import { Tree } from './tree';
import { TreeControlProps } from './types';
@@ -14,7 +14,7 @@ export const TreeControl = forwardRef( function ForwardedTree(
{ items, ...props }: TreeControlProps,
ref: React.ForwardedRef< HTMLOListElement >
) {
- const linkedTree = useLinkedTree( items );
+ const linkedTree = createLinkedTree( items, props.createValue );
return ;
} );
diff --git a/packages/js/components/src/experimental-tree-control/tree-item.scss b/packages/js/components/src/experimental-tree-control/tree-item.scss
index e0bd703c354d4..8e5f7a95e8383 100644
--- a/packages/js/components/src/experimental-tree-control/tree-item.scss
+++ b/packages/js/components/src/experimental-tree-control/tree-item.scss
@@ -6,6 +6,8 @@ $control-size: $gap-large;
&--highlighted {
> .experimental-woocommerce-tree-item__heading {
background-color: $gray-100;
+ outline: 1.5px solid var( --wp-admin-theme-color );
+ outline-offset: -1.5px;
}
}
diff --git a/packages/js/components/src/experimental-tree-control/tree-item.tsx b/packages/js/components/src/experimental-tree-control/tree-item.tsx
index 6ca9aa1457777..afbfe87089560 100644
--- a/packages/js/components/src/experimental-tree-control/tree-item.tsx
+++ b/packages/js/components/src/experimental-tree-control/tree-item.tsx
@@ -24,21 +24,25 @@ export const TreeItem = forwardRef( function ForwardedTreeItem(
treeItemProps,
headingProps,
treeProps,
- expander: { isExpanded, onToggleExpand },
selection,
- highlighter: { isHighlighted },
getLabel,
} = useTreeItem( {
...props,
ref,
} );
- function handleEscapePress(
- event: React.KeyboardEvent< HTMLInputElement >
- ) {
+ function handleKeyDown( event: React.KeyboardEvent< HTMLElement > ) {
if ( event.key === 'Escape' && props.onEscape ) {
event.preventDefault();
props.onEscape();
+ } else if ( event.key === 'ArrowLeft' ) {
+ if ( item.index !== undefined ) {
+ props.onExpand?.( item.index, false );
+ }
+ } else if ( event.key === 'ArrowRight' ) {
+ if ( item.index !== undefined ) {
+ props.onExpand?.( item.index, true );
+ }
}
}
@@ -50,7 +54,7 @@ export const TreeItem = forwardRef( function ForwardedTreeItem(
'experimental-woocommerce-tree-item',
{
'experimental-woocommerce-tree-item--highlighted':
- isHighlighted,
+ props.isHighlighted,
}
) }
>
@@ -67,7 +71,7 @@ export const TreeItem = forwardRef( function ForwardedTreeItem(
}
checked={ selection.checkedStatus === 'checked' }
onChange={ selection.onSelectChild }
- onKeyDown={ handleEscapePress }
+ onKeyDown={ handleKeyDown }
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore __nextHasNoMarginBottom is a valid prop
__nextHasNoMarginBottom={ true }
@@ -80,7 +84,7 @@ export const TreeItem = forwardRef( function ForwardedTreeItem(
onChange={ ( event ) =>
selection.onSelectChild( event.target.checked )
}
- onKeyDown={ handleEscapePress }
+ onKeyDown={ handleKeyDown }
/>
) }
@@ -94,11 +98,21 @@ export const TreeItem = forwardRef( function ForwardedTreeItem(
{ Boolean( item.children?.length ) && (
- { Boolean( item.children.length ) && isExpanded && (
-
+ { Boolean( item.children.length ) && item.data.isExpanded && (
+
) }
);
diff --git a/packages/js/components/src/experimental-tree-control/tree.scss b/packages/js/components/src/experimental-tree-control/tree.scss
index 7225539369d11..341d3a225ab3a 100644
--- a/packages/js/components/src/experimental-tree-control/tree.scss
+++ b/packages/js/components/src/experimental-tree-control/tree.scss
@@ -16,8 +16,9 @@
width: 100%;
cursor: default;
&:hover,
- &:focus-within {
- outline: 1.5px solid var( --wp-admin-theme-color );
+ &:focus-within,
+ &--highlighted {
+ outline: 1.5px solid var(--wp-admin-theme-color);
outline-offset: -1.5px;
background-color: $gray-100;
}
diff --git a/packages/js/components/src/experimental-tree-control/tree.tsx b/packages/js/components/src/experimental-tree-control/tree.tsx
index 08957af0904f2..ccbe500829aad 100644
--- a/packages/js/components/src/experimental-tree-control/tree.tsx
+++ b/packages/js/components/src/experimental-tree-control/tree.tsx
@@ -14,6 +14,7 @@ import { useMergeRefs } from '@wordpress/compose';
import { useTree } from './hooks/use-tree';
import { TreeItem } from './tree-item';
import { TreeProps } from './types';
+import { countNumberOfNodes } from './linked-tree-utils';
export const Tree = forwardRef( function ForwardedTree(
props: TreeProps,
@@ -27,6 +28,8 @@ export const Tree = forwardRef( function ForwardedTree(
ref,
} );
+ const numberOfItems = countNumberOfNodes( items );
+
const isCreateButtonVisible =
props.shouldShowCreateButton &&
props.shouldShowCreateButton( props.createValue );
@@ -45,7 +48,12 @@ export const Tree = forwardRef( function ForwardedTree(
{ items.map( ( child, index ) => (
{
(
rootListRef.current
- ?.closest( 'ol[role="tree"]' )
+ ?.closest( 'ol[role="listbox"]' )
?.parentElement?.querySelector(
'.experimental-woocommerce-tree__button'
) as HTMLButtonElement
@@ -67,7 +75,17 @@ export const Tree = forwardRef( function ForwardedTree(
) : null }
{ isCreateButtonVisible && (