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 && (