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:
parent
178466aa06
commit
e62f28b3ca
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: update
|
||||
|
||||
Use inline popover for select tree and allow selecting items through ENTER or comma
|
|
@ -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
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
} );
|
||||
|
||||
|
|
|
@ -23,6 +23,7 @@ export function useTree( {
|
|||
onTreeBlur,
|
||||
onCreateNew,
|
||||
shouldShowCreateButton,
|
||||
onFirstItemLoop,
|
||||
...props
|
||||
}: TreeProps ) {
|
||||
return {
|
||||
|
|
|
@ -59,6 +59,7 @@ export const Tree = forwardRef( function ForwardedTree(
|
|||
) as HTMLButtonElement
|
||||
)?.focus();
|
||||
} }
|
||||
onFirstItemLoop={ props.onFirstItemLoop }
|
||||
onEscape={ props.onEscape }
|
||||
/>
|
||||
) ) }
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: update
|
||||
|
||||
Add 'help' to taxonomy block and to SelectTree
|
|
@ -35,6 +35,10 @@
|
|||
"placeholder": {
|
||||
"type": "string",
|
||||
"__experimentalRole": "content"
|
||||
},
|
||||
"help": {
|
||||
"type": "string",
|
||||
"__experimentalRole": "content"
|
||||
}
|
||||
},
|
||||
"supports": {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -148,6 +148,11 @@ export const TagField: React.FC< TagFieldProps > = ( {
|
|||
)
|
||||
);
|
||||
onChange( [ ...value, ...newItems ] );
|
||||
} else {
|
||||
onChange( [
|
||||
...value,
|
||||
mapFromTreeItemToTag( selectedItems ),
|
||||
] );
|
||||
}
|
||||
} }
|
||||
onRemove={ ( removedItems ) => {
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
Significance: patch
|
||||
Type: fix
|
||||
Comment: Fix E2E test about Categories field
|
||||
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
Loading…
Reference in New Issue