SelectTree: keep the focus on the input while navigating between menu items (#49989)

* Commit at a functional state

* Change role to 'listbox'

* Add --highlighted class rules

* Fix overflow in create category modal

* Add countNumberOfItems
Fix multiple bugs
Refactor
Rename and move use-linked-tree file to linked-tree-utils

* Add comments

* Escape regExp

* Allow to select/remove with the enter key

* Add changelogs

* Fix unit tests

* Fix bug on css selector, since role was changed

* Fix bug in index calculation and handle focus on checkboxes and expander button correctly

* Only add activedescendant when something is highlighted

preventDefault when pressing arrowUp

* Fix bug: items array was being used instead of using linked tree

* Call onSelect when pressing enter

* Add guards to prevent tests breaking

* Add additional tests for SelectTree

* Add comments and rename some functions in linked-tree-utils
This commit is contained in:
Nathan Silveira 2024-07-31 11:30:32 -03:00 committed by GitHub
parent 3dd6a4037b
commit d3bd80fc61
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 585 additions and 137 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Update SelectTree and Tree controls to allow highlighting items without focus

View File

@ -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 (
<div className={ classes }>
{ 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 }

View File

@ -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
}

View File

@ -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 ( props.onSelect && item && ! isAlreadySelected ) {
props.onSelect( item );
setInputValue( '' );
recalculateInputValue();
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 )
);
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( {
<SelectedItems
isReadOnly={ isReadOnly }
ref={ selectedItemsFocusHandle }
items={ ( props.selected as Item[] ) || [] }
items={
! Array.isArray( props.selected )
? [ props.selected ]
: props.selected
}
getItemLabel={ ( item ) =>
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={ () => {

View File

@ -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', () => {
<SelectTree { ...DEFAULT_PROPS } />
);
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(
<SelectTree { ...DEFAULT_PROPS } />
);
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( <SelectTree { ...DEFAULT_PROPS } /> );
queryByRole( 'textbox' )?.focus();
const { queryByText, queryByLabelText, queryByRole } = render(
<SelectTree { ...DEFAULT_PROPS } />
);
queryByRole( 'combobox' )?.focus();
expect( queryByText( 'Item 1' ) ).toBeInTheDocument();
expect( queryByText( 'Item 2' ) ).not.toBeInTheDocument();
@ -72,7 +113,7 @@ describe( 'SelectTree', () => {
const { queryAllByRole, queryByRole } = render(
<SelectTree { ...DEFAULT_PROPS } selected={ [ mockItems[ 0 ] ] } />
);
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 "<createValue>" 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( <TestComponent /> );
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( <TestComponent /> );
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(
<TestComponent multiple />
);
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)
} );
} );

View File

@ -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;
}

View File

@ -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: {

View File

@ -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 {

View File

@ -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;
}

View File

@ -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 <Tree { ...props } ref={ ref } items={ linkedTree } />;
} );

View File

@ -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;
}
}

View File

@ -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 ) && (
<div className="experimental-woocommerce-tree-item__expander">
<Button
icon={ isExpanded ? chevronUp : chevronDown }
onClick={ onToggleExpand }
icon={
item.data.isExpanded ? chevronUp : chevronDown
}
onClick={ () => {
if ( item.index !== undefined ) {
props.onExpand?.(
item.index,
! item.data.isExpanded
);
}
} }
onKeyDown={ handleKeyDown }
className="experimental-woocommerce-tree-item__expander"
aria-label={
isExpanded
item.data.isExpanded
? __( 'Collapse', 'woocommerce' )
: __( 'Expand', 'woocommerce' )
}
@ -107,8 +121,13 @@ export const TreeItem = forwardRef( function ForwardedTreeItem(
) }
</div>
{ Boolean( item.children.length ) && isExpanded && (
<Tree { ...treeProps } onEscape={ props.onEscape } />
{ Boolean( item.children.length ) && item.data.isExpanded && (
<Tree
{ ...treeProps }
highlightedIndex={ props.highlightedIndex }
onExpand={ props.onExpand }
onEscape={ props.onEscape }
/>
) }
</li>
);

View File

@ -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;
}

View File

@ -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 ) => (
<TreeItem
{ ...treeItemProps }
isExpanded={ props.isExpanded }
isHighlighted={
props.highlightedIndex === child.index
}
onExpand={ props.onExpand }
highlightedIndex={ props.highlightedIndex }
isExpanded={ child.data.isExpanded }
key={ child.data.value }
item={ child }
index={ index }
@ -53,7 +61,7 @@ export const Tree = forwardRef( function ForwardedTree(
onLastItemLoop={ () => {
(
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 && (
<Button
className="experimental-woocommerce-tree__button"
id={
'woocommerce-experimental-tree-control__menu-item-' +
numberOfItems
}
className={ classNames(
'experimental-woocommerce-tree__button',
{
'experimental-woocommerce-tree__button--highlighted':
props.highlightedIndex === numberOfItems,
}
) }
onClick={ () => {
if ( props.onCreateNew ) {
props.onCreateNew();

View File

@ -4,10 +4,15 @@ export interface Item {
label: string;
}
export type AugmentedItem = Item & {
isExpanded: boolean;
};
export interface LinkedTree {
parent?: LinkedTree;
data: Item;
data: AugmentedItem;
children: LinkedTree[];
index?: number;
}
export type CheckedStatus = 'checked' | 'unchecked' | 'indeterminate';
@ -18,6 +23,11 @@ type BaseTreeProps = {
* a list of items if it is true.
*/
selected?: Item | Item[];
onExpand?( index: number, value: boolean ): void;
highlightedIndex?: number;
/**
* Whether the tree items are single or multiple selected.
*/
@ -137,6 +147,7 @@ export type TreeItemProps = BaseTreeProps &
item: LinkedTree;
index: number;
isFocused?: boolean;
isHighlighted?: boolean;
getLabel?( item: LinkedTree ): JSX.Element;
shouldItemBeExpanded?( item: LinkedTree ): boolean;
onLastItemLoop?( event: React.KeyboardEvent< HTMLDivElement > ): void;

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Allow popovers to exceed modal's limits in taxonomy

View File

@ -14,7 +14,10 @@
&__optional {
color: $gray-700;
}
}
.components-modal__content { // make sure that inline popovers can exceed the dialog's limits
overflow: visible;
}
}
.components-base-control {
margin-bottom: $gap;