Select tree dropdown menu SlotFill support (#37574)

* Add initial select tree popover and modal story

* Add slot fill popover support

* Add changelog

* Remove unneeded use of combobox ref in select-tree

* Fix lint errors

* Address PR feedback and fix issue with parent select control

* Add changelog
This commit is contained in:
louwie17 2023-04-14 04:22:59 -03:00 committed by GitHub
parent a939744ac2
commit 6e2c11f556
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 364 additions and 160 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Update select tree control dropdown menu for custom slot fill support for display within Modals

View File

@ -10,6 +10,7 @@ import {
useState,
createPortal,
Children,
useLayoutEffect,
} from '@wordpress/element';
/**
@ -37,13 +38,19 @@ export const Menu = ( {
const [ boundingRect, setBoundingRect ] = useState< DOMRect >();
const selectControlMenuRef = useRef< HTMLDivElement >( null );
useEffect( () => {
if ( selectControlMenuRef.current?.parentElement ) {
useLayoutEffect( () => {
if (
selectControlMenuRef.current?.parentElement &&
selectControlMenuRef.current?.parentElement.clientWidth > 0
) {
setBoundingRect(
selectControlMenuRef.current.parentElement.getBoundingClientRect()
);
}
}, [ selectControlMenuRef.current ] );
}, [
selectControlMenuRef.current,
selectControlMenuRef.current?.clientWidth,
] );
// Scroll the selected item into view when the menu opens.
useEffect( () => {

View File

@ -1 +1,2 @@
export * from './select-tree';
export * from './select-tree-menu';

View File

@ -0,0 +1,154 @@
/**
* External dependencies
*/
import { Popover, Spinner } from '@wordpress/components';
import classnames from 'classnames';
import {
createElement,
useEffect,
useRef,
createPortal,
useLayoutEffect,
useState,
} from '@wordpress/element';
/**
* Internal dependencies
*/
import {
LinkedTree,
Tree,
TreeControlProps,
} from '../experimental-tree-control';
type MenuProps = {
isOpen: boolean;
isLoading?: boolean;
position?: Popover.Position;
scrollIntoViewOnOpen?: boolean;
items: LinkedTree[];
treeRef?: React.ForwardedRef< HTMLOListElement >;
onClose?: () => void;
} & Omit< TreeControlProps, 'items' >;
export const SelectTreeMenu = ( {
isLoading,
isOpen,
className,
position = 'bottom center',
scrollIntoViewOnOpen = false,
items,
treeRef: ref,
onClose = () => {},
shouldShowCreateButton,
...props
}: MenuProps ) => {
const [ boundingRect, setBoundingRect ] = useState< DOMRect >();
const selectControlMenuRef = useRef< HTMLDivElement >( null );
useLayoutEffect( () => {
if (
selectControlMenuRef.current?.parentElement &&
selectControlMenuRef.current?.parentElement.clientWidth > 0
) {
setBoundingRect(
selectControlMenuRef.current.parentElement.getBoundingClientRect()
);
}
}, [
selectControlMenuRef.current,
selectControlMenuRef.current?.clientWidth,
] );
// Scroll the selected item into view when the menu opens.
useEffect( () => {
if ( isOpen && scrollIntoViewOnOpen ) {
selectControlMenuRef.current?.scrollIntoView();
}
}, [ isOpen, scrollIntoViewOnOpen ] );
const shouldItemBeExpanded = ( item: LinkedTree ): boolean => {
if ( ! props.createValue || ! item.children?.length ) return false;
return item.children.some( ( child ) => {
if (
new RegExp( props.createValue || '', 'ig' ).test(
child.data.label
)
) {
return true;
}
return shouldItemBeExpanded( child );
} );
};
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/click-events-have-key-events */
/* Disabled because of the onmouseup on the ul element below. */
return (
<div
ref={ selectControlMenuRef }
className="woocommerce-experimental-select-tree-control__menu"
>
<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 }
className={ classnames(
'woocommerce-experimental-select-tree-control__popover-menu',
className,
{
'is-open': isOpen,
'has-results': items.length > 0,
}
) }
position={ position }
animate={ false }
onFocusOutside={ () => {
onClose();
} }
>
{ isOpen && (
<div>
{ isLoading ? (
<div
style={ {
width: boundingRect?.width,
} }
>
<Spinner />
</div>
) : (
<Tree
{ ...props }
id={ `${ props.id }-menu` }
ref={ ref }
items={ items }
onTreeBlur={ onClose }
shouldItemBeExpanded={
shouldItemBeExpanded
}
shouldShowCreateButton={
shouldShowCreateButton
}
style={ {
width: boundingRect?.width,
} }
/>
) }
</div>
) }
</Popover>
</div>
</div>
);
/* 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

@ -2,24 +2,20 @@
/**
* External dependencies
*/
import { createElement, useRef, useState } from 'react';
import { createElement, useState } from '@wordpress/element';
import classNames from 'classnames';
import { search } from '@wordpress/icons';
import { Dropdown, Spinner } from '@wordpress/components';
import { useInstanceId } from '@wordpress/compose';
/**
* Internal dependencies
*/
import { useLinkedTree } from '../experimental-tree-control/hooks/use-linked-tree';
import { Tree } from '../experimental-tree-control/tree';
import {
Item,
LinkedTree,
TreeControlProps,
} from '../experimental-tree-control/types';
import { Item, TreeControlProps } from '../experimental-tree-control/types';
import { SelectedItems } from '../experimental-select-control/selected-items';
import { ComboBox } from '../experimental-select-control/combo-box';
import { SuffixIcon } from '../experimental-select-control/suffix-icon';
import { SelectTreeMenu } from './select-tree-menu';
interface SelectTreeProps extends TreeControlProps {
id: string;
@ -44,156 +40,115 @@ export const SelectTree = function SelectTree( {
...props
}: SelectTreeProps ) {
const linkedTree = useLinkedTree( items );
const menuInstanceId = useInstanceId(
SelectTree,
'woocommerce-select-tree-control__menu'
);
const [ isFocused, setIsFocused ] = useState( false );
const comboBoxRef = useRef< HTMLDivElement >( null );
// getting the parent's parent div width to set the width of the dropdown
const comboBoxWidth =
comboBoxRef.current?.parentElement?.parentElement?.getBoundingClientRect()
.width;
const shouldItemBeExpanded = ( item: LinkedTree ): boolean => {
if ( ! props.createValue || ! item.children?.length ) return false;
return item.children.some( ( child ) => {
if (
new RegExp( props.createValue || '', 'ig' ).test(
child.data.label
)
) {
return true;
}
return shouldItemBeExpanded( child );
} );
};
const [ isOpen, setIsOpen ] = useState( false );
return (
<Dropdown
<div
className="woocommerce-experimental-select-tree-control__dropdown"
contentClassName="woocommerce-experimental-select-tree-control__dropdown-content"
focusOnMount={ false }
renderContent={ ( { onClose } ) =>
isLoading ? (
<div
style={ {
width: comboBoxWidth,
} }
>
<Spinner />
</div>
) : (
<Tree
{ ...props }
id={ `${ props.id }-menu` }
ref={ ref }
items={ linkedTree }
onTreeBlur={ onClose }
shouldItemBeExpanded={ shouldItemBeExpanded }
shouldShowCreateButton={ shouldShowCreateButton }
style={ {
width: comboBoxWidth,
} }
/>
)
}
renderToggle={ ( { isOpen, onToggle, onClose } ) => (
<div
className={ classNames(
'woocommerce-experimental-select-control',
{
'is-focused': isFocused,
}
) }
tabIndex={ -1 }
>
<div
className={ classNames(
'woocommerce-experimental-select-control',
{
'is-focused': isFocused,
}
) }
>
<label
htmlFor={ `${ props.id }-input` }
id={ `${ props.id }-label` }
className="woocommerce-experimental-select-control__label"
>
<label
htmlFor={ `${ props.id }-input` }
id={ `${ props.id }-label` }
className="woocommerce-experimental-select-control__label"
>
{ props.label }
</label>
<ComboBox
comboBoxProps={ {
className:
'woocommerce-experimental-select-control__combo-box-wrapper',
ref: comboBoxRef,
role: 'combobox',
'aria-expanded': isOpen,
'aria-haspopup': 'tree',
'aria-labelledby': `${ props.id }-label`,
'aria-owns': `${ props.id }-menu`,
{ props.label }
</label>
<ComboBox
comboBoxProps={ {
className:
'woocommerce-experimental-select-control__combo-box-wrapper',
role: 'combobox',
'aria-expanded': isOpen,
'aria-haspopup': 'tree',
'aria-labelledby': `${ props.id }-label`,
'aria-owns': `${ props.id }-menu`,
} }
inputProps={ {
className:
'woocommerce-experimental-select-control__input',
id: `${ props.id }-input`,
'aria-autocomplete': 'list',
'aria-controls': `${ props.id }-menu`,
autoComplete: 'off',
onFocus: () => {
if ( ! isOpen ) {
setIsOpen( true );
}
setIsFocused( true );
},
onBlur: ( event ) => {
// if blurring to an element inside the dropdown, don't close it
if (
isOpen &&
! document
.querySelector( '.' + menuInstanceId )
?.contains( event.relatedTarget )
) {
setIsOpen( false );
}
setIsFocused( false );
},
onKeyDown: ( event ) => {
setIsOpen( true );
if ( event.key === 'ArrowDown' ) {
event.preventDefault();
// focus on the first element from the Popover
(
document.querySelector(
`.${ menuInstanceId } input, .${ menuInstanceId } button`
) as HTMLInputElement | HTMLButtonElement
)?.focus();
}
if ( event.key === 'Tab' ) {
setIsOpen( false );
}
},
onChange: ( event ) =>
onInputChange &&
onInputChange( event.target.value ),
placeholder,
} }
suffix={ suffix }
>
<SelectedItems
items={ ( props.selected as Item[] ) || [] }
getItemLabel={ ( item ) => item?.label || '' }
getItemValue={ ( item ) => item?.value || '' }
onRemove={ ( item ) => {
if ( ! Array.isArray( item ) && props.onRemove ) {
props.onRemove( item );
setIsOpen( false );
}
} }
inputProps={ {
className:
'woocommerce-experimental-select-control__input',
id: `${ props.id }-input`,
'aria-autocomplete': 'list',
'aria-controls': `${ props.id }-menu`,
autoComplete: 'off',
onFocus: () => {
if ( ! isOpen ) {
onToggle();
}
setIsFocused( true );
},
onBlur: ( event ) => {
// if blurring to an element inside the dropdown, don't close it
if (
isOpen &&
! document
.querySelector(
'.woocommerce-experimental-select-control ~ .components-popover'
)
?.contains( event.relatedTarget )
) {
onClose();
}
setIsFocused( false );
},
onKeyDown: ( event ) => {
const baseQuery =
'.woocommerce-experimental-select-tree-control__dropdown > .components-popover';
if ( event.key === 'ArrowDown' ) {
event.preventDefault();
// focus on the first element from the Popover
(
document.querySelector(
`${ baseQuery } input, ${ baseQuery } button`
) as
| HTMLInputElement
| HTMLButtonElement
)?.focus();
}
if ( event.key === 'Tab' ) {
onClose();
}
},
onChange: ( event ) =>
onInputChange &&
onInputChange( event.target.value ),
placeholder,
} }
suffix={ suffix }
>
<SelectedItems
items={ ( props.selected as Item[] ) || [] }
getItemLabel={ ( item ) => item?.label || '' }
getItemValue={ ( item ) => item?.value || '' }
onRemove={ ( item ) => {
if (
! Array.isArray( item ) &&
props.onRemove
) {
props.onRemove( item );
onClose();
}
} }
getSelectedItemProps={ () => ( {} ) }
/>
</ComboBox>
</div>
) }
/>
getSelectedItemProps={ () => ( {} ) }
/>
</ComboBox>
</div>
<SelectTreeMenu
{ ...props }
id={ `${ props.id }-menu` }
className={ menuInstanceId.toString() }
ref={ ref }
isOpen={ isOpen }
items={ linkedTree }
shouldShowCreateButton={ shouldShowCreateButton }
onClose={ () => setIsOpen( false ) }
/>
</div>
);
};

View File

@ -1,14 +1,15 @@
/**
* External dependencies
*/
import React, { createElement } from 'react';
import React, { createElement, useState } from 'react';
import { Button, Modal, SlotFillProvider } from '@wordpress/components';
/**
* Internal dependencies
*/
import { SelectTree } from '../select-tree';
import { Item } from '../../experimental-tree-control/types';
import { SelectTreeMenuSlot } from '../select-tree-menu';
const listItems: Item[] = [
{ value: '1', label: 'Technology' },
@ -95,6 +96,72 @@ export const MultipleSelectTree: React.FC = () => {
);
};
export const SingleWithinModalUsingBodyDropdownPlacement: React.FC = () => {
const [ isOpen, setOpen ] = useState( true );
const [ value, setValue ] = useState( '' );
const [ selected, setSelected ] = useState< Item[] >( [] );
const items = filterItems( listItems, value );
return (
<SlotFillProvider>
Selected: { JSON.stringify( selected ) }
<Button onClick={ () => setOpen( true ) }>
Show Dropdown in Modal
</Button>
{ isOpen && (
<Modal
title="Dropdown Modal"
onRequestClose={ () => setOpen( false ) }
>
<SelectTree
id="multiple-select-tree"
label="Multiple Select Tree"
multiple
items={ items }
selected={ selected }
shouldNotRecursivelySelect
shouldShowCreateButton={ ( typedValue ) =>
! value ||
listItems.findIndex(
( item ) => item.label === typedValue
) === -1
}
createValue={ value }
// eslint-disable-next-line no-alert
onCreateNew={ () => alert( 'create new called' ) }
onInputChange={ ( a ) => setValue( a || '' ) }
onSelect={ ( selectedItems ) => {
if ( Array.isArray( selectedItems ) ) {
setSelected( [
...selected,
...selectedItems,
] );
}
} }
onRemove={ ( removedItems ) => {
const newValues = Array.isArray( removedItems )
? selected.filter(
( item ) =>
! removedItems.some(
( { value: removedValue } ) =>
item.value === removedValue
)
)
: selected.filter(
( item ) =>
item.value !== removedItems.value
);
setSelected( newValues );
} }
/>
</Modal>
) }
<SelectTreeMenuSlot />
</SlotFillProvider>
);
};
export default {
title: 'WooCommerce Admin/experimental/SelectTreeControl',
component: SelectTree,

View File

@ -99,7 +99,10 @@ export {
TreeControl as __experimentalTreeControl,
Item as TreeItemType,
} from './experimental-tree-control';
export { SelectTree as __experimentalSelectTreeControl } from './experimental-select-tree-control';
export {
SelectTree as __experimentalSelectTreeControl,
SelectTreeMenuSlot as __experimentalSelectTreeMenuSlot,
} from './experimental-select-tree-control';
export { default as TreeSelectControl } from './tree-select-control';
// Exports below can be removed once the @woocommerce/product-editor package is released.

View File

@ -0,0 +1,4 @@
Significance: minor
Type: fix
Fix issue with category parent select control clearing search value when typing.

View File

@ -32,6 +32,15 @@ type CreateCategoryModalProps = {
onCreate: ( newCategory: ProductCategory ) => void;
};
function getCategoryItemLabel( item: ProductCategoryNode | null ): string {
return item?.name || '';
}
function getCategoryItemValue(
item: ProductCategoryNode | null
): string | number {
return item?.id || '';
}
export const CreateCategoryModal: React.FC< CreateCategoryModalProps > = ( {
initialCategoryName,
onCancel,
@ -109,8 +118,8 @@ export const CreateCategoryModal: React.FC< CreateCategoryModalProps > = ( {
onRemove={ () => setCategoryParent( null ) }
onInputChange={ debouncedSearch }
getFilteredItems={ getFilteredItems }
getItemLabel={ ( item ) => item?.name || '' }
getItemValue={ ( item ) => item?.id || '' }
getItemLabel={ getCategoryItemLabel }
getItemValue={ getCategoryItemValue }
>
{ ( {
items,