SelectTree: allow navigation between items and input using tab and arrow keys (#49251)

* Hide help text in create-taxonomy-modal

* Expose onKeyDown and onBlur events in SelectedItems

* Increment isEventOutside function to consider remove button tags as well

* Add label to remove all button

* Handle navigation between added items and input using tab and arrow keys

* Add changelogs

* Prevent unwanted scroll

* Revert change in product editor and fix issue in component itself

* Change onKeyDown and onBlur to optional

* Move tags navigation to selectedItems and create lastRemoveButtonRef to avoid long CSS query

* Small refactor

* use useImperativeHandle hook in SelectedItems to abstract focusing on last remove button
This commit is contained in:
Nathan Silveira 2024-07-16 16:26:29 -03:00 committed by GitHub
parent eb2b3da95b
commit 7335645b70
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 219 additions and 89 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
SelectTree: allow navigation between items and input using tab and arrow keys

View File

@ -2,14 +2,23 @@
* External dependencies
*/
import classnames from 'classnames';
import { createElement } from '@wordpress/element';
import {
createElement,
forwardRef,
useImperativeHandle,
useRef,
} from '@wordpress/element';
import { decodeEntities } from '@wordpress/html-entities';
/**
* Internal dependencies
*/
import Tag from '../tag';
import { getItemLabelType, getItemValueType } from './types';
import {
getItemLabelType,
getItemValueType,
SelectedItemFocusHandle,
} from './types';
type SelectedItemsProps< ItemType > = {
isReadOnly: boolean;
@ -22,16 +31,23 @@ type SelectedItemsProps< ItemType > = {
[ key: string ]: string;
};
onRemove: ( item: ItemType ) => void;
onBlur?: ( event: React.FocusEvent ) => void;
onSelectedItemsEnd?: () => void;
};
export const SelectedItems = < ItemType, >( {
isReadOnly,
items,
getItemLabel,
getItemValue,
getSelectedItemProps,
onRemove,
}: SelectedItemsProps< ItemType > ) => {
const PrivateSelectedItems = < ItemType, >(
{
isReadOnly,
items,
getItemLabel,
getItemValue,
getSelectedItemProps,
onRemove,
onBlur,
onSelectedItemsEnd,
}: SelectedItemsProps< ItemType >,
ref: React.ForwardedRef< SelectedItemFocusHandle >
) => {
const classes = classnames(
'woocommerce-experimental-select-control__selected-items',
{
@ -39,6 +55,16 @@ export const SelectedItems = < ItemType, >( {
}
);
const lastRemoveButtonRef = useRef< HTMLButtonElement >( null );
useImperativeHandle(
ref,
() => {
return () => lastRemoveButtonRef.current?.focus();
},
[]
);
if ( isReadOnly ) {
return (
<div className={ classes }>
@ -71,6 +97,42 @@ export const SelectedItems = < ItemType, >( {
onClick={ ( event ) => {
event.preventDefault();
} }
onKeyDown={ ( event ) => {
if (
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 (
event.key === 'ArrowRight' &&
onSelectedItemsEnd
) {
onSelectedItemsEnd();
}
} else if (
event.key === 'ArrowUp' ||
event.key === 'ArrowDown'
) {
event.preventDefault(); // prevent unwanted scroll
}
} }
onBlur={ onBlur }
>
{ /* eslint-disable-next-line @typescript-eslint/ban-ts-comment */ }
{ /* @ts-ignore Additional props are not required. */ }
@ -78,6 +140,11 @@ export const SelectedItems = < ItemType, >( {
id={ getItemValue( item ) }
remove={ () => () => onRemove( item ) }
label={ getItemLabel( item ) }
ref={
index === items.length - 1
? lastRemoveButtonRef
: undefined
}
/>
</div>
);
@ -85,3 +152,9 @@ export const SelectedItems = < ItemType, >( {
</div>
);
};
export const SelectedItems = forwardRef( PrivateSelectedItems ) as < ItemType >(
props: SelectedItemsProps< ItemType > & {
ref?: React.ForwardedRef< SelectedItemFocusHandle >;
}
) => ReturnType< typeof PrivateSelectedItems >;

View File

@ -58,3 +58,5 @@ export type getItemLabelType< ItemType > = ( item: ItemType | null ) => string;
export type getItemValueType< ItemType > = (
item: ItemType | null
) => string | number;
export type SelectedItemFocusHandle = () => void;

View File

@ -8,6 +8,7 @@ import {
useEffect,
useState,
Fragment,
useRef,
} from '@wordpress/element';
import { useInstanceId } from '@wordpress/compose';
import { BaseControl, Button, TextControl } from '@wordpress/components';
@ -25,6 +26,7 @@ import { ComboBox } from '../experimental-select-control/combo-box';
import { SuffixIcon } from '../experimental-select-control/suffix-icon';
import { SelectTreeMenu } from './select-tree-menu';
import { escapeHTML } from '../utils';
import { SelectedItemFocusHandle } from '../experimental-select-control/types';
interface SelectTreeProps extends TreeControlProps {
id: string;
@ -48,7 +50,7 @@ export const SelectTree = function SelectTree( {
initialInputValue,
onInputChange,
shouldShowCreateButton,
help = __( 'Separate with commas or the Enter key.', 'woocommerce' ),
help,
isClearingAllowed = false,
onClear = () => {},
...props
@ -63,6 +65,8 @@ export const SelectTree = function SelectTree( {
'woocommerce-select-tree-control__menu'
) as string;
const selectedItemsFocusHandle = useRef< SelectedItemFocusHandle >( null );
function isEventOutside( event: React.FocusEvent ) {
const isInsideSelect = document
.getElementById( selectTreeInstanceId )
@ -74,7 +78,10 @@ export const SelectTree = function SelectTree( {
'.woocommerce-experimental-select-tree-control__popover-menu'
)
?.contains( event.relatedTarget );
return ! ( isInsideSelect || isInsidePopover );
const isInRemoveTag = event.relatedTarget?.classList.contains(
'woocommerce-tag__remove'
);
return ! isInsideSelect && ! isInRemoveTag && ! isInsidePopover;
}
const recalculateInputValue = () => {
@ -141,11 +148,12 @@ export const SelectTree = function SelectTree( {
}
},
onBlur: ( event ) => {
if ( isOpen && isEventOutside( event ) ) {
event.preventDefault();
if ( isEventOutside( event ) ) {
setIsOpen( false );
setIsFocused( false );
recalculateInputValue();
}
setIsFocused( false );
},
onKeyDown: ( event ) => {
setIsOpen( true );
@ -157,12 +165,10 @@ export const SelectTree = function SelectTree( {
`#${ menuInstanceId } input, #${ menuInstanceId } button`
) as HTMLInputElement | HTMLButtonElement
)?.focus();
}
if ( event.key === 'Tab' || event.key === 'Escape' ) {
} else if ( event.key === 'Tab' || event.key === 'Escape' ) {
setIsOpen( false );
recalculateInputValue();
}
if ( event.key === ',' || event.key === 'Enter' ) {
} else if ( event.key === ',' || event.key === 'Enter' ) {
event.preventDefault();
const item = items.find(
( i ) => i.label === escapeHTML( inputValue )
@ -179,6 +185,14 @@ export const SelectTree = function SelectTree( {
setInputValue( '' );
recalculateInputValue();
}
} else if (
( event.key === 'ArrowLeft' || 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();
}
},
onChange: ( event ) => {
@ -219,7 +233,14 @@ export const SelectTree = function SelectTree( {
<BaseControl
label={ props.label }
id={ `${ props.id }-input` }
help={ help }
help={
props.multiple && ! help
? __(
'Separate with commas or the Enter key.',
'woocommerce'
)
: help
}
>
<>
{ props.multiple ? (
@ -236,7 +257,13 @@ export const SelectTree = function SelectTree( {
suffix={
<div className="woocommerce-experimental-select-control__suffix-items">
{ isClearingAllowed && isOpen && (
<Button onClick={ handleClear }>
<Button
label={ __(
'Remove all',
'woocommerce'
) }
onClick={ handleClear }
>
<SuffixIcon
className="woocommerce-experimental-select-control__icon-clear"
icon={ closeSmall }
@ -253,6 +280,7 @@ export const SelectTree = function SelectTree( {
>
<SelectedItems
isReadOnly={ isReadOnly }
ref={ selectedItemsFocusHandle }
items={ ( props.selected as Item[] ) || [] }
getItemLabel={ ( item ) =>
item?.label || ''
@ -268,6 +296,13 @@ export const SelectTree = function SelectTree( {
props.onRemove( item );
}
} }
onBlur={ ( event ) => {
if ( isEventOutside( event ) ) {
setIsOpen( false );
setIsFocused( false );
}
} }
onSelectedItemsEnd={ focusOnInput }
getSelectedItemProps={ () => ( {} ) }
/>
</ComboBox>

View File

@ -2,16 +2,20 @@
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { createElement, Fragment, useState } from '@wordpress/element';
import {
createElement,
forwardRef,
Fragment,
useState,
} from '@wordpress/element';
import classnames from 'classnames';
import { Button, Popover } from '@wordpress/components';
import { Icon, closeSmall } from '@wordpress/icons';
import { decodeEntities } from '@wordpress/html-entities';
import { withInstanceId } from '@wordpress/compose';
import { Ref } from 'react';
import { useInstanceId } from '@wordpress/compose';
type Props = {
/** A unique ID for this instance of the component. This is automatically generated by withInstanceId. */
instanceId: number | string;
/** The name for this item, displayed as the tag's text. */
label: string;
/** A unique ID for this item. This is used to identify the item when the remove button is clicked. */
@ -28,72 +32,84 @@ type Props = {
className?: string;
};
const Tag: React.VFC< Props > = ( {
id,
instanceId,
label,
popoverContents,
remove,
screenReaderLabel,
className,
} ) => {
const [ isVisible, setIsVisible ] = useState( false );
const Tag = forwardRef(
(
{
id,
label,
popoverContents,
remove,
screenReaderLabel,
className,
}: Props,
removeButtonRef: Ref< HTMLButtonElement >
) => {
const [ isVisible, setIsVisible ] = useState( false );
screenReaderLabel = screenReaderLabel || label;
if ( ! label ) {
// A null label probably means something went wrong
// @todo Maybe this should be a loading indicator?
return null;
}
label = decodeEntities( label );
const classes = classnames( 'woocommerce-tag', className, {
'has-remove': !! remove,
} );
const labelId = `woocommerce-tag__label-${ instanceId }`;
const labelTextNode = (
<Fragment>
<span className="screen-reader-text">{ screenReaderLabel }</span>
<span aria-hidden="true">{ label }</span>
</Fragment>
);
const instanceId = useInstanceId( Tag ) as string;
return (
<span className={ classes }>
{ popoverContents ? (
<Button
className="woocommerce-tag__text"
id={ labelId }
onClick={ () => setIsVisible( true ) }
>
{ labelTextNode }
</Button>
) : (
<span className="woocommerce-tag__text" id={ labelId }>
{ labelTextNode }
screenReaderLabel = screenReaderLabel || label;
if ( ! label ) {
// A null label probably means something went wrong
// @todo Maybe this should be a loading indicator?
return null;
}
label = decodeEntities( label );
const classes = classnames( 'woocommerce-tag', className, {
'has-remove': !! remove,
} );
const labelId = `woocommerce-tag__label-${ instanceId }`;
const labelTextNode = (
<Fragment>
<span className="screen-reader-text">
{ screenReaderLabel }
</span>
) }
{ popoverContents && isVisible && (
<Popover onClose={ () => setIsVisible( false ) }>
{ popoverContents }
</Popover>
) }
{ remove && (
<Button
className="woocommerce-tag__remove"
onClick={ remove( id ) }
// translators: %s is the name of the tag being removed.
label={ sprintf( __( 'Remove %s', 'woocommerce' ), label ) }
aria-describedby={ labelId }
>
<Icon
icon={ closeSmall }
size={ 20 }
className="clear-icon"
/>
</Button>
) }
</span>
);
};
<span aria-hidden="true">{ label }</span>
</Fragment>
);
export default withInstanceId( Tag );
return (
<span className={ classes }>
{ popoverContents ? (
<Button
className="woocommerce-tag__text"
id={ labelId }
onClick={ () => setIsVisible( true ) }
>
{ labelTextNode }
</Button>
) : (
<span className="woocommerce-tag__text" id={ labelId }>
{ labelTextNode }
</span>
) }
{ popoverContents && isVisible && (
<Popover onClose={ () => setIsVisible( false ) }>
{ popoverContents }
</Popover>
) }
{ remove && (
<Button
className="woocommerce-tag__remove"
ref={ removeButtonRef }
onClick={ remove( id ) }
label={ sprintf(
// translators: %s is the name of the tag being removed.
__( 'Remove %s', 'woocommerce' ),
label
) }
aria-describedby={ labelId }
>
<Icon
icon={ closeSmall }
size={ 20 }
className="clear-icon"
/>
</Button>
) }
</span>
);
}
);
export default Tag;