Allow selecting categories through keyboard (#49049)

* Remove portal and use inline popup

* Allow selecting existing values through , or ENTER

* Add help to taxonomy block

* Provide help attribute to taxonomy block

* Add changelogs

* Remove help text

* Add help text and a11y instructions

* Try to fix unit test

* Add changelog

* Fix E2E test

* Allow navigating up from first list item into the input

* Add scenario for single selection as well
This commit is contained in:
Nathan Silveira 2024-07-05 14:20:28 -03:00 committed by GitHub
parent 178466aa06
commit e62f28b3ca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 176 additions and 107 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Use inline popover for select tree and allow selecting items through ENTER or comma

View File

@ -7,7 +7,6 @@ import {
createElement,
useEffect,
useRef,
createPortal,
useLayoutEffect,
useState,
} from '@wordpress/element';
@ -44,6 +43,7 @@ export const SelectTreeMenu = ( {
onClose = () => {},
onEscape,
shouldShowCreateButton,
onFirstItemLoop,
...props
}: MenuProps ) => {
const [ boundingRect, setBoundingRect ] = useState< DOMRect >();
@ -93,9 +93,9 @@ export const SelectTreeMenu = ( {
>
<div>
<Popover
// @ts-expect-error this prop does exist, see: https://github.com/WordPress/gutenberg/blob/trunk/packages/components/src/popover/index.tsx#L180.
__unstableSlotName="woocommerce-select-tree-control-menu"
focusOnMount={ false }
// @ts-expect-error this prop does exist
inline
className={ classnames(
'woocommerce-experimental-select-tree-control__popover-menu',
className,
@ -136,6 +136,7 @@ export const SelectTreeMenu = ( {
shouldShowCreateButton={
shouldShowCreateButton
}
onFirstItemLoop={ onFirstItemLoop }
onEscape={ onEscape }
style={ {
width: boundingRect?.width,
@ -150,12 +151,3 @@ export const SelectTreeMenu = ( {
);
/* eslint-enable jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/click-events-have-key-events */
};
export const SelectTreeMenuSlot: React.FC = () =>
createPortal(
<div aria-live="off">
{ /* @ts-expect-error name does exist on PopoverSlot see: https://github.com/WordPress/gutenberg/blob/trunk/packages/components/src/popover/index.tsx#L555 */ }
<Popover.Slot name="woocommerce-select-tree-control-menu" />
</div>,
document.body
);

View File

@ -3,10 +3,17 @@
*/
import { chevronDown, chevronUp, closeSmall } from '@wordpress/icons';
import classNames from 'classnames';
import { createElement, useEffect, useState } from '@wordpress/element';
import {
createElement,
useEffect,
useState,
Fragment,
} from '@wordpress/element';
import { useInstanceId } from '@wordpress/compose';
import { BaseControl, Button, TextControl } from '@wordpress/components';
import { decodeEntities } from '@wordpress/html-entities';
import { __ } from '@wordpress/i18n';
import { speak } from '@wordpress/a11y';
/**
* Internal dependencies
@ -26,6 +33,7 @@ interface SelectTreeProps extends TreeControlProps {
isLoading?: boolean;
disabled?: boolean;
label: string | JSX.Element;
help?: string | JSX.Element;
onInputChange?: ( value: string | undefined ) => void;
initialInputValue?: string | undefined;
isClearingAllowed?: boolean;
@ -40,6 +48,7 @@ export const SelectTree = function SelectTree( {
initialInputValue,
onInputChange,
shouldShowCreateButton,
help = __( 'Separate with commas or the Enter key.', 'woocommerce' ),
isClearingAllowed = false,
onClear = () => {},
...props
@ -110,6 +119,14 @@ export const SelectTree = function SelectTree( {
autoComplete: 'off',
disabled,
onFocus: ( event ) => {
if ( props.multiple ) {
speak(
__(
'To select existing items, type its exact label and separate with commas or the Enter key.',
'woocommerce'
)
);
}
if ( ! isOpen ) {
setIsOpen( true );
}
@ -145,6 +162,24 @@ export const SelectTree = function SelectTree( {
setIsOpen( false );
recalculateInputValue();
}
if ( event.key === ',' || event.key === 'Enter' ) {
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 ( props.onSelect && item && ! isAlreadySelected ) {
props.onSelect( item );
setInputValue( '' );
recalculateInputValue();
}
}
},
onChange: ( event ) => {
if ( onInputChange ) {
@ -181,100 +216,111 @@ export const SelectTree = function SelectTree( {
}
) }
>
<BaseControl label={ props.label } id={ `${ props.id }-input` }>
{ props.multiple ? (
<ComboBox
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={
<div className="woocommerce-experimental-select-control__suffix-items">
{ isClearingAllowed && isOpen && (
<Button onClick={ handleClear }>
<SuffixIcon
className="woocommerce-experimental-select-control__icon-clear"
icon={ closeSmall }
/>
</Button>
) }
<SuffixIcon
icon={
isOpen ? chevronUp : chevronDown
<BaseControl
label={ props.label }
id={ `${ props.id }-input` }
help={ help }
>
<>
{ props.multiple ? (
<ComboBox
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={
<div className="woocommerce-experimental-select-control__suffix-items">
{ isClearingAllowed && isOpen && (
<Button onClick={ handleClear }>
<SuffixIcon
className="woocommerce-experimental-select-control__icon-clear"
icon={ closeSmall }
/>
</Button>
) }
<SuffixIcon
icon={
isOpen ? chevronUp : chevronDown
}
/>
</div>
}
>
<SelectedItems
isReadOnly={ isReadOnly }
items={ ( props.selected as Item[] ) || [] }
getItemLabel={ ( item ) =>
item?.label || ''
}
getItemValue={ ( item ) =>
item?.value || ''
}
onRemove={ ( item ) => {
if (
! Array.isArray( item ) &&
props.onRemove
) {
props.onRemove( item );
}
/>
</div>
}
>
<SelectedItems
isReadOnly={ isReadOnly }
items={ ( props.selected as Item[] ) || [] }
getItemLabel={ ( item ) => item?.label || '' }
getItemValue={ ( item ) => item?.value || '' }
onRemove={ ( item ) => {
if (
! Array.isArray( item ) &&
props.onRemove
) {
props.onRemove( item );
} }
getSelectedItemProps={ () => ( {} ) }
/>
</ComboBox>
) : (
<TextControl
{ ...inputProps }
value={ decodeEntities(
props.createValue || ''
) }
onChange={ ( value ) => {
if ( onInputChange ) onInputChange( value );
const item = items.find(
( i ) => i.label === escapeHTML( value )
);
if ( props.onSelect && item ) {
props.onSelect( item );
}
if ( ! value && props.onRemove ) {
props.onRemove(
props.selected as Item
);
}
} }
getSelectedItemProps={ () => ( {} ) }
/>
</ComboBox>
) : (
<TextControl
{ ...inputProps }
value={ decodeEntities( props.createValue || '' ) }
onChange={ ( value ) => {
if ( onInputChange ) onInputChange( value );
const item = items.find(
( i ) => i.label === escapeHTML( value )
);
if ( props.onSelect && item ) {
) }
<SelectTreeMenu
{ ...props }
onSelect={ ( item ) => {
if ( ! props.multiple && onInputChange ) {
onInputChange( ( item as Item ).label );
setIsOpen( false );
setIsFocused( false );
focusOnInput();
}
if ( props.onSelect ) {
props.onSelect( item );
}
if ( ! value && props.onRemove ) {
props.onRemove( props.selected as Item );
}
} }
id={ menuInstanceId }
ref={ ref }
isEventOutside={ isEventOutside }
isLoading={ isLoading }
isOpen={ isOpen }
items={ linkedTree }
shouldShowCreateButton={ shouldShowCreateButton }
onClose={ () => {
setIsOpen( false );
} }
onFirstItemLoop={ focusOnInput }
/>
) }
</>
</BaseControl>
</div>
<SelectTreeMenu
{ ...props }
onSelect={ ( item ) => {
if ( ! props.multiple && onInputChange ) {
onInputChange( ( item as Item ).label );
setIsOpen( false );
setIsFocused( false );
focusOnInput();
}
if ( props.onSelect ) {
props.onSelect( item );
}
} }
id={ menuInstanceId }
ref={ ref }
isEventOutside={ isEventOutside }
isLoading={ isLoading }
isOpen={ isOpen }
items={ linkedTree }
shouldShowCreateButton={ shouldShowCreateButton }
onEscape={ () => {
focusOnInput();
setIsOpen( false );
} }
onClose={ () => {
setIsOpen( false );
} }
/>
</div>
);
};

View File

@ -111,6 +111,7 @@ export function useKeyboard( {
onCollapse,
onToggleExpand,
onLastItemLoop,
onFirstItemLoop,
}: {
item: LinkedTree;
isExpanded: boolean;
@ -118,6 +119,7 @@ export function useKeyboard( {
onCollapse(): void;
onToggleExpand(): void;
onLastItemLoop?( event: React.KeyboardEvent< HTMLDivElement > ): void;
onFirstItemLoop?( event: React.KeyboardEvent< HTMLDivElement > ): void;
} ) {
function onKeyDown( event: React.KeyboardEvent< HTMLDivElement > ) {
if ( event.code === 'ArrowRight' ) {
@ -159,6 +161,9 @@ export function useKeyboard( {
if ( event.code === 'ArrowDown' && ! element && onLastItemLoop ) {
onLastItemLoop( event );
}
if ( event.code === 'ArrowUp' && ! element && onFirstItemLoop ) {
onFirstItemLoop( event );
}
}
if ( event.code === 'Home' ) {
@ -174,5 +179,5 @@ export function useKeyboard( {
}
}
return { onKeyDown, onLastItemLoop };
return { onKeyDown };
}

View File

@ -29,6 +29,7 @@ export function useTreeItem( {
onCreateNew,
shouldShowCreateButton,
onLastItemLoop,
onFirstItemLoop,
onTreeBlur,
...props
}: TreeItemProps ) {
@ -64,6 +65,7 @@ export function useTreeItem( {
const { onKeyDown } = useKeyboard( {
...expander,
onLastItemLoop,
onFirstItemLoop,
item,
} );

View File

@ -23,6 +23,7 @@ export function useTree( {
onTreeBlur,
onCreateNew,
shouldShowCreateButton,
onFirstItemLoop,
...props
}: TreeProps ) {
return {

View File

@ -59,6 +59,7 @@ export const Tree = forwardRef( function ForwardedTree(
) as HTMLButtonElement
)?.focus();
} }
onFirstItemLoop={ props.onFirstItemLoop }
onEscape={ props.onEscape }
/>
) ) }

View File

@ -76,6 +76,8 @@ type BaseTreeProps = {
* Called when the create button is clicked to help closing any related popover.
*/
onTreeBlur?(): void;
onFirstItemLoop?( event: React.KeyboardEvent< HTMLDivElement > ): void;
/**
* Called when the escape key is pressed.
*/

View File

@ -99,10 +99,7 @@ export {
TreeControl as __experimentalTreeControl,
Item as TreeItemType,
} from './experimental-tree-control';
export {
SelectTree as __experimentalSelectTreeControl,
SelectTreeMenuSlot as __experimentalSelectTreeMenuSlot,
} from './experimental-select-tree-control';
export { SelectTree as __experimentalSelectTreeControl } from './experimental-select-tree-control';
export { default as TreeSelectControl } from './tree-select-control';
export { default as PhoneNumberInput } from './phone-number-input';

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Add 'help' to taxonomy block and to SelectTree

View File

@ -35,6 +35,10 @@
"placeholder": {
"type": "string",
"__experimentalRole": "content"
},
"help": {
"type": "string",
"__experimentalRole": "content"
}
},
"supports": {

View File

@ -26,9 +26,11 @@ import type {
TaxonomyMetadata,
} from '../../../types';
import useProductEntityProp from '../../../hooks/use-product-entity-prop';
import { Label } from '../../../components/label/label';
interface TaxonomyBlockAttributes extends BlockAttributes {
label: string;
help?: string;
slug: string;
property: string;
createTitle: string;
@ -52,6 +54,7 @@ export function Edit( {
);
const {
label,
help,
slug,
property,
createTitle,
@ -117,7 +120,7 @@ export function Edit( {
'woocommerce-taxonomy-select'
) as string
}
label={ label }
label={ <Label label={ label } tooltip={ help } /> }
isLoading={ isResolving }
disabled={ disabled }
multiple

View File

@ -148,6 +148,11 @@ export const TagField: React.FC< TagFieldProps > = ( {
)
);
onChange( [ ...value, ...newItems ] );
} else {
onChange( [
...value,
mapFromTreeItemToTag( selectedItems ),
] );
}
} }
onRemove={ ( removedItems ) => {

View File

@ -0,0 +1,5 @@
Significance: patch
Type: fix
Comment: Fix E2E test about Categories field

View File

@ -62,9 +62,7 @@ test.describe( 'General tab', { tag: '@gutenberg' }, () => {
await clickOnTab( 'Organization', page );
await page
.locator( '[id^="woocommerce-taxonomy-select-"]' )
.click();
await page.getByLabel( 'Categories' ).click();
await page.locator( 'text=Create new' ).click();
@ -80,7 +78,7 @@ test.describe( 'General tab', { tag: '@gutenberg' }, () => {
} )
.click();
await page.locator( '[id^="tag-field-"]' ).click();
await page.getByLabel( 'Tags' ).click();
await page.locator( 'text=Create new' ).click();