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:
parent
eb2b3da95b
commit
7335645b70
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: update
|
||||
|
||||
SelectTree: allow navigation between items and input using tab and arrow keys
|
|
@ -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 >;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in New Issue