From e62f28b3ca987d04d8a95625aea45c3321a9aa31 Mon Sep 17 00:00:00 2001 From: Nathan Silveira Date: Fri, 5 Jul 2024 14:20:28 -0300 Subject: [PATCH] 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 --- .../components/changelog/tweak-select-tree-2 | 4 + .../select-tree-menu.tsx | 16 +- .../select-tree.tsx | 216 +++++++++++------- .../hooks/use-keyboard.ts | 7 +- .../hooks/use-tree-item.ts | 2 + .../hooks/use-tree.ts | 1 + .../src/experimental-tree-control/tree.tsx | 1 + .../src/experimental-tree-control/types.ts | 2 + packages/js/components/src/index.ts | 5 +- .../changelog/tweak-select-tree-2 | 4 + .../src/blocks/generic/taxonomy/block.json | 4 + .../src/blocks/generic/taxonomy/edit.tsx | 5 +- .../src/components/tags-field/tag-field.tsx | 5 + .../woocommerce/changelog/tweak-select-tree-2 | 5 + ...anization-tab-product-block-editor.spec.js | 6 +- 15 files changed, 176 insertions(+), 107 deletions(-) create mode 100644 packages/js/components/changelog/tweak-select-tree-2 create mode 100644 packages/js/product-editor/changelog/tweak-select-tree-2 create mode 100644 plugins/woocommerce/changelog/tweak-select-tree-2 diff --git a/packages/js/components/changelog/tweak-select-tree-2 b/packages/js/components/changelog/tweak-select-tree-2 new file mode 100644 index 00000000000..ef4df972846 --- /dev/null +++ b/packages/js/components/changelog/tweak-select-tree-2 @@ -0,0 +1,4 @@ +Significance: minor +Type: update + +Use inline popover for select tree and allow selecting items through ENTER or comma diff --git a/packages/js/components/src/experimental-select-tree-control/select-tree-menu.tsx b/packages/js/components/src/experimental-select-tree-control/select-tree-menu.tsx index c4ec3f8b03e..1b7267389b4 100644 --- a/packages/js/components/src/experimental-select-tree-control/select-tree-menu.tsx +++ b/packages/js/components/src/experimental-select-tree-control/select-tree-menu.tsx @@ -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 = ( { >
- createPortal( -
- { /* @ts-expect-error name does exist on PopoverSlot see: https://github.com/WordPress/gutenberg/blob/trunk/packages/components/src/popover/index.tsx#L555 */ } - -
, - document.body - ); diff --git a/packages/js/components/src/experimental-select-tree-control/select-tree.tsx b/packages/js/components/src/experimental-select-tree-control/select-tree.tsx index 53acfe8a634..c23a64d0ffd 100644 --- a/packages/js/components/src/experimental-select-tree-control/select-tree.tsx +++ b/packages/js/components/src/experimental-select-tree-control/select-tree.tsx @@ -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( { } ) } > - - { props.multiple ? ( - - { isClearingAllowed && isOpen && ( - - ) } - + <> + { props.multiple ? ( + + { isClearingAllowed && isOpen && ( + + ) } + +
+ } + > + + item?.label || '' + } + getItemValue={ ( item ) => + item?.value || '' + } + onRemove={ ( item ) => { + if ( + ! Array.isArray( item ) && + props.onRemove + ) { + props.onRemove( item ); } - /> - - } - > - item?.label || '' } - getItemValue={ ( item ) => item?.value || '' } - onRemove={ ( item ) => { - if ( - ! Array.isArray( item ) && - props.onRemove - ) { - props.onRemove( item ); + } } + getSelectedItemProps={ () => ( {} ) } + /> + + ) : ( + { + 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={ () => ( {} ) } /> - - ) : ( - { - if ( onInputChange ) onInputChange( value ); - const item = items.find( - ( i ) => i.label === escapeHTML( value ) - ); - if ( 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 } /> - ) } + - { - 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 ); - } } - /> ); }; diff --git a/packages/js/components/src/experimental-tree-control/hooks/use-keyboard.ts b/packages/js/components/src/experimental-tree-control/hooks/use-keyboard.ts index 54db7555200..a4f539f2b41 100644 --- a/packages/js/components/src/experimental-tree-control/hooks/use-keyboard.ts +++ b/packages/js/components/src/experimental-tree-control/hooks/use-keyboard.ts @@ -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 }; } diff --git a/packages/js/components/src/experimental-tree-control/hooks/use-tree-item.ts b/packages/js/components/src/experimental-tree-control/hooks/use-tree-item.ts index 35a08d19042..9d90fe406bb 100644 --- a/packages/js/components/src/experimental-tree-control/hooks/use-tree-item.ts +++ b/packages/js/components/src/experimental-tree-control/hooks/use-tree-item.ts @@ -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, } ); diff --git a/packages/js/components/src/experimental-tree-control/hooks/use-tree.ts b/packages/js/components/src/experimental-tree-control/hooks/use-tree.ts index 656473e9456..393a02873cb 100644 --- a/packages/js/components/src/experimental-tree-control/hooks/use-tree.ts +++ b/packages/js/components/src/experimental-tree-control/hooks/use-tree.ts @@ -23,6 +23,7 @@ export function useTree( { onTreeBlur, onCreateNew, shouldShowCreateButton, + onFirstItemLoop, ...props }: TreeProps ) { return { diff --git a/packages/js/components/src/experimental-tree-control/tree.tsx b/packages/js/components/src/experimental-tree-control/tree.tsx index e33baf34a25..08957af0904 100644 --- a/packages/js/components/src/experimental-tree-control/tree.tsx +++ b/packages/js/components/src/experimental-tree-control/tree.tsx @@ -59,6 +59,7 @@ export const Tree = forwardRef( function ForwardedTree( ) as HTMLButtonElement )?.focus(); } } + onFirstItemLoop={ props.onFirstItemLoop } onEscape={ props.onEscape } /> ) ) } diff --git a/packages/js/components/src/experimental-tree-control/types.ts b/packages/js/components/src/experimental-tree-control/types.ts index e30172d0d44..6d759e3801e 100644 --- a/packages/js/components/src/experimental-tree-control/types.ts +++ b/packages/js/components/src/experimental-tree-control/types.ts @@ -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. */ diff --git a/packages/js/components/src/index.ts b/packages/js/components/src/index.ts index 2e2b570b5d3..ff64f547fda 100644 --- a/packages/js/components/src/index.ts +++ b/packages/js/components/src/index.ts @@ -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'; diff --git a/packages/js/product-editor/changelog/tweak-select-tree-2 b/packages/js/product-editor/changelog/tweak-select-tree-2 new file mode 100644 index 00000000000..82314a6b4f2 --- /dev/null +++ b/packages/js/product-editor/changelog/tweak-select-tree-2 @@ -0,0 +1,4 @@ +Significance: minor +Type: update + +Add 'help' to taxonomy block and to SelectTree diff --git a/packages/js/product-editor/src/blocks/generic/taxonomy/block.json b/packages/js/product-editor/src/blocks/generic/taxonomy/block.json index 98abfd3d3fb..69b5423852b 100644 --- a/packages/js/product-editor/src/blocks/generic/taxonomy/block.json +++ b/packages/js/product-editor/src/blocks/generic/taxonomy/block.json @@ -35,6 +35,10 @@ "placeholder": { "type": "string", "__experimentalRole": "content" + }, + "help": { + "type": "string", + "__experimentalRole": "content" } }, "supports": { diff --git a/packages/js/product-editor/src/blocks/generic/taxonomy/edit.tsx b/packages/js/product-editor/src/blocks/generic/taxonomy/edit.tsx index e6dd85b43e9..0f700d00eaa 100644 --- a/packages/js/product-editor/src/blocks/generic/taxonomy/edit.tsx +++ b/packages/js/product-editor/src/blocks/generic/taxonomy/edit.tsx @@ -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={