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:
parent
a939744ac2
commit
6e2c11f556
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: update
|
||||
|
||||
Update select tree control dropdown menu for custom slot fill support for display within Modals
|
|
@ -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( () => {
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
export * from './select-tree';
|
||||
export * from './select-tree-menu';
|
||||
|
|
|
@ -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
|
||||
);
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: fix
|
||||
|
||||
Fix issue with category parent select control clearing search value when typing.
|
|
@ -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,
|
||||
|
|
Loading…
Reference in New Issue