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:
parent
3dd6a4037b
commit
d3bd80fc61
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: update
|
||||
|
||||
Update SelectTree and Tree controls to allow highlighting items without focus
|
|
@ -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 }
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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={ () => {
|
||||
|
|
|
@ -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)
|
||||
} );
|
||||
} );
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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: {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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 } />;
|
||||
} );
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
Significance: patch
|
||||
Type: fix
|
||||
|
||||
Allow popovers to exceed modal's limits in taxonomy
|
|
@ -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;
|
||||
|
|
Loading…
Reference in New Issue