diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md
index b06aeab54a67e1..39015157a5025d 100644
--- a/packages/components/CHANGELOG.md
+++ b/packages/components/CHANGELOG.md
@@ -2,6 +2,10 @@
## Unreleased
+### Experimental
+
+- `CustomSelectControlV2`: Adapt component for legacy usage ([#57902](https://github.com/WordPress/gutenberg/pull/57902)).
+
## 26.0.0 (2024-02-09)
### Breaking Changes
diff --git a/packages/components/src/custom-select-control-v2/README.md b/packages/components/src/custom-select-control-v2/README.md
index 3cd9c3f8534e76..634b25808c7d4a 100644
--- a/packages/components/src/custom-select-control-v2/README.md
+++ b/packages/components/src/custom-select-control-v2/README.md
@@ -1,11 +1,93 @@
+# CustomSelect
+
This feature is still experimental. “Experimental” means this is an early implementation subject to drastic and breaking changes.
-### `CustomSelect`
-
Used to render a customizable select control component.
+## Development guidelines
+
+### Usage
+
+#### Uncontrolled Mode
+
+CustomSelect can be used in an uncontrolled mode, where the component manages its own state. In this mode, the `defaultValue` prop can be used to set the initial selected value. If this prop is not set, the first value from the children will be selected by default.
+
+```jsx
+const UncontrolledCustomSelect = () => (
+
+
+ { /* The `defaultValue` since it wasn't defined */ }
+ Blue
+
+
+ Purple
+
+
+ Pink
+
+
+);
+```
+
+#### Controlled Mode
+
+CustomSelect can also be used in a controlled mode, where the parent component specifies the `value` and the `onChange` props to control selection.
+
+```jsx
+const ControlledCustomSelect = () => {
+ const [ value, setValue ] = useState< string | string[] >();
+
+ const renderControlledValue = ( renderValue: string | string[] ) => (
+ <>
+ { /* Custom JSX to display `renderValue` item */ }
+ >
+ );
+
+ return (
+ {
+ setValue( nextValue );
+ props.onChange?.( nextValue );
+ } }
+ value={ value }
+ >
+ { [ 'blue', 'purple', 'pink' ].map( ( option ) => (
+
+ { renderControlledValue( option ) }
+
+ ) ) }
+
+ );
+};
+```
+
+#### Multiple Selection
+
+Multiple selection can be enabled by using an array for the `value` and
+`defaultValue` props. The argument of the `onChange` function will also change accordingly.
+
+```jsx
+const MultiSelectCustomSelect = () => (
+
+ { [ 'blue', 'purple', 'pink' ].map( ( item ) => (
+
+ { item }
+
+ ) ) }
+
+);
+```
+
+### Components and Sub-components
+
+CustomSelect is comprised of two individual components:
+
+- `CustomSelect`: a wrapper component and context provider. It is responsible for managing the state of the `CustomSelectItem` children.
+- `CustomSelectItem`: renders a single select item. The first `CustomSelectItem` child will be used as the `defaultValue` when `defaultValue` is undefined.
+
#### Props
The component accepts the following props:
@@ -16,37 +98,45 @@ The child elements. This should be composed of CustomSelect.Item components.
- Required: yes
-##### `defaultValue`: `string`
+##### `defaultValue`: `string | string[]`
An optional default value for the control. If left `undefined`, the first non-disabled item will be used.
- Required: no
+##### `hideLabelFromVision`: `boolean`
+
+Used to visually hide the label. It will always be visible to screen readers.
+
+- Required: no
+- Default: `false`
+
##### `label`: `string`
Label for the control.
- Required: yes
-##### `onChange`: `( newValue: string ) => void`
+##### `onChange`: `( newValue: string | string[] ) => void`
A function that receives the new value of the input.
- Required: no
-##### `renderSelectedValue`: `( selectValue: string ) => React.ReactNode`
+##### `renderSelectedValue`: `( selectValue: string | string[] ) => React.ReactNode`
Can be used to render select UI with custom styled values.
- Required: no
-##### `size`: `'default' | 'large'`
+##### `size`: `'default' | 'compact'`
The size of the control.
- Required: no
+- Default: `'default'`
-##### `value`: `string`
+##### `value`: `string | string[]`
Can be used to externally control the value of the control.
diff --git a/packages/components/src/custom-select-control-v2/custom-select-item.tsx b/packages/components/src/custom-select-control-v2/custom-select-item.tsx
new file mode 100644
index 00000000000000..b3e8cfeb1363e1
--- /dev/null
+++ b/packages/components/src/custom-select-control-v2/custom-select-item.tsx
@@ -0,0 +1,29 @@
+/**
+ * WordPress dependencies
+ */
+import { useContext } from '@wordpress/element';
+import { Icon, check } from '@wordpress/icons';
+/**
+ * Internal dependencies
+ */
+import type { CustomSelectItemProps } from './types';
+import type { WordPressComponentProps } from '../context';
+import * as Styled from './styles';
+import { CustomSelectContext } from './custom-select';
+
+export function CustomSelectItem( {
+ children,
+ ...props
+}: WordPressComponentProps< CustomSelectItemProps, 'div', false > ) {
+ const customSelectContext = useContext( CustomSelectContext );
+ return (
+
+ { children ?? props.value }
+
+
+
+
+ );
+}
+
+export default CustomSelectItem;
diff --git a/packages/components/src/custom-select-control-v2/custom-select.tsx b/packages/components/src/custom-select-control-v2/custom-select.tsx
new file mode 100644
index 00000000000000..2bd1221f1381eb
--- /dev/null
+++ b/packages/components/src/custom-select-control-v2/custom-select.tsx
@@ -0,0 +1,122 @@
+/**
+ * WordPress dependencies
+ */
+import { createContext, useMemo } from '@wordpress/element';
+import { __, sprintf } from '@wordpress/i18n';
+import { Icon, chevronDown } from '@wordpress/icons';
+
+/**
+ * Internal dependencies
+ */
+import { VisuallyHidden } from '..';
+import * as Styled from './styles';
+import type {
+ CustomSelectContext as CustomSelectContextType,
+ CustomSelectStore,
+ CustomSelectButtonProps,
+ _CustomSelectProps,
+} from './types';
+import {
+ contextConnectWithoutRef,
+ useContextSystem,
+ type WordPressComponentProps,
+} from '../context';
+
+export const CustomSelectContext =
+ createContext< CustomSelectContextType >( undefined );
+
+function defaultRenderSelectedValue(
+ value: CustomSelectButtonProps[ 'value' ]
+) {
+ const isValueEmpty = Array.isArray( value )
+ ? value.length === 0
+ : value === undefined || value === null;
+
+ if ( isValueEmpty ) {
+ return __( 'Select an item' );
+ }
+
+ if ( Array.isArray( value ) ) {
+ return value.length === 1
+ ? value[ 0 ]
+ : // translators: %s: number of items selected (it will always be 2 or more items)
+ sprintf( __( '%s items selected' ), value.length );
+ }
+
+ return value;
+}
+
+const UnconnectedCustomSelectButton = (
+ props: Omit<
+ WordPressComponentProps<
+ CustomSelectButtonProps & CustomSelectStore,
+ 'button',
+ false
+ >,
+ 'onChange'
+ >
+) => {
+ const {
+ renderSelectedValue,
+ size = 'default',
+ store,
+ ...restProps
+ } = useContextSystem( props, 'CustomSelectControlButton' );
+
+ const { value: currentValue } = store.useState();
+
+ const computedRenderSelectedValue = useMemo(
+ () => renderSelectedValue ?? defaultRenderSelectedValue,
+ [ renderSelectedValue ]
+ );
+
+ return (
+
+ { computedRenderSelectedValue( currentValue ) }
+
+
+ );
+};
+
+const CustomSelectButton = contextConnectWithoutRef(
+ UnconnectedCustomSelectButton,
+ 'CustomSelectControlButton'
+);
+
+function _CustomSelect( props: _CustomSelectProps & CustomSelectStore ) {
+ const {
+ children,
+ hideLabelFromVision = false,
+ label,
+ store,
+ ...restProps
+ } = props;
+
+ return (
+ <>
+ { hideLabelFromVision ? ( // TODO: Replace with BaseControl
+ { label }
+ ) : (
+
+ { label }
+
+ ) }
+
+
+
+ { children }
+
+
+ >
+ );
+}
+
+export default _CustomSelect;
diff --git a/packages/components/src/custom-select-control-v2/default-component/index.tsx b/packages/components/src/custom-select-control-v2/default-component/index.tsx
new file mode 100644
index 00000000000000..746861ed03b5a5
--- /dev/null
+++ b/packages/components/src/custom-select-control-v2/default-component/index.tsx
@@ -0,0 +1,24 @@
+/**
+ * External dependencies
+ */
+// eslint-disable-next-line no-restricted-imports
+import * as Ariakit from '@ariakit/react';
+/**
+ * Internal dependencies
+ */
+import _CustomSelect from '../custom-select';
+import type { CustomSelectProps } from '../types';
+
+function CustomSelect( props: CustomSelectProps ) {
+ const { defaultValue, onChange, value, ...restProps } = props;
+ // Forward props + store from v2 implementation
+ const store = Ariakit.useSelectStore( {
+ setValue: ( nextValue ) => onChange?.( nextValue ),
+ defaultValue,
+ value,
+ } );
+
+ return <_CustomSelect { ...restProps } store={ store } />;
+}
+
+export default CustomSelect;
diff --git a/packages/components/src/custom-select-control-v2/index.tsx b/packages/components/src/custom-select-control-v2/index.tsx
index 338c7198ce37f6..58ca626be91619 100644
--- a/packages/components/src/custom-select-control-v2/index.tsx
+++ b/packages/components/src/custom-select-control-v2/index.tsx
@@ -1,105 +1,5 @@
-/**
- * External dependencies
- */
-// eslint-disable-next-line no-restricted-imports
-import * as Ariakit from '@ariakit/react';
-/**
- * WordPress dependencies
- */
-import { createContext, useContext } from '@wordpress/element';
-import { __, sprintf } from '@wordpress/i18n';
-
/**
* Internal dependencies
*/
-import * as Styled from './styles';
-import type {
- CustomSelectProps,
- CustomSelectItemProps,
- CustomSelectContext as CustomSelectContextType,
-} from './types';
-import type { WordPressComponentProps } from '../context';
-
-export const CustomSelectContext =
- createContext< CustomSelectContextType >( undefined );
-
-function defaultRenderSelectedValue( value: CustomSelectProps[ 'value' ] ) {
- const isValueEmpty = Array.isArray( value )
- ? value.length === 0
- : value === undefined || value === null;
-
- if ( isValueEmpty ) {
- return __( 'Select an item' );
- }
-
- if ( Array.isArray( value ) ) {
- return value.length === 1
- ? value[ 0 ]
- : // translators: %s: number of items selected (it will always be 2 or more items)
- sprintf( __( '%s items selected' ), value.length );
- }
-
- return value;
-}
-
-export function CustomSelect( {
- children,
- defaultValue,
- label,
- onChange,
- size = 'default',
- value,
- renderSelectedValue,
- ...props
-}: WordPressComponentProps< CustomSelectProps, 'button', false > ) {
- const store = Ariakit.useSelectStore( {
- setValue: ( nextValue ) => onChange?.( nextValue ),
- defaultValue,
- value,
- // fix for Safari bug: https://github.com/WordPress/gutenberg/issues/55023#issuecomment-1834035917
- virtualFocus: false,
- } );
-
- const { value: currentValue } = store.useState();
-
- const computedRenderSelectedValue =
- renderSelectedValue ?? defaultRenderSelectedValue;
-
- return (
- <>
-
- { label }
-
-
- { computedRenderSelectedValue( currentValue ) }
-
-
-
-
- { children }
-
-
- >
- );
-}
-
-export function CustomSelectItem( {
- children,
- ...props
-}: WordPressComponentProps< CustomSelectItemProps, 'div', false > ) {
- const customSelectContext = useContext( CustomSelectContext );
- return (
-
- { children ?? props.value }
-
-
- );
-}
+export { default as CustomSelect } from './legacy-adapter';
+export { default as CustomSelectItem } from './custom-select-item';
diff --git a/packages/components/src/custom-select-control-v2/legacy-adapter.tsx b/packages/components/src/custom-select-control-v2/legacy-adapter.tsx
new file mode 100644
index 00000000000000..ab7fc74d977929
--- /dev/null
+++ b/packages/components/src/custom-select-control-v2/legacy-adapter.tsx
@@ -0,0 +1,25 @@
+/**
+ * Internal dependencies
+ */
+import _LegacyCustomSelect from './legacy-component';
+import _NewCustomSelect from './default-component';
+import type { CustomSelectProps, LegacyCustomSelectProps } from './types';
+import type { WordPressComponentProps } from '../context';
+
+function isLegacy( props: any ): props is LegacyCustomSelectProps {
+ return typeof props.options !== 'undefined';
+}
+
+function CustomSelect(
+ props:
+ | LegacyCustomSelectProps
+ | WordPressComponentProps< CustomSelectProps, 'button', false >
+) {
+ if ( isLegacy( props ) ) {
+ return <_LegacyCustomSelect { ...props } />;
+ }
+
+ return <_NewCustomSelect { ...props } />;
+}
+
+export default CustomSelect;
diff --git a/packages/components/src/custom-select-control-v2/legacy-component/index.tsx b/packages/components/src/custom-select-control-v2/legacy-component/index.tsx
new file mode 100644
index 00000000000000..49c640581c3772
--- /dev/null
+++ b/packages/components/src/custom-select-control-v2/legacy-component/index.tsx
@@ -0,0 +1,133 @@
+/**
+ * External dependencies
+ */
+// eslint-disable-next-line no-restricted-imports
+import * as Ariakit from '@ariakit/react';
+/**
+ * WordPress dependencies
+ */
+import { useMemo } from '@wordpress/element';
+/**
+ * Internal dependencies
+ */
+import _CustomSelect from '../custom-select';
+import type { LegacyCustomSelectProps } from '../types';
+import { CustomSelectItem } from '..';
+import * as Styled from '../styles';
+import { ContextSystemProvider } from '../../context';
+
+function CustomSelect( props: LegacyCustomSelectProps ) {
+ const {
+ __experimentalShowSelectedHint,
+ __next40pxDefaultSize = false,
+ options,
+ onChange,
+ size = 'default',
+ value,
+ ...restProps
+ } = props;
+
+ // Forward props + store from v2 implementation
+ const store = Ariakit.useSelectStore( {
+ async setValue( nextValue ) {
+ if ( ! onChange ) return;
+
+ // Executes the logic in a microtask after the popup is closed.
+ // This is simply to ensure the isOpen state matches that in Downshift.
+ await Promise.resolve();
+ const state = store.getState();
+
+ const changeObject = {
+ highlightedIndex: state.renderedItems.findIndex(
+ ( item ) => item.value === nextValue
+ ),
+ inputValue: '',
+ isOpen: state.open,
+ selectedItem: {
+ name: nextValue as string,
+ key: nextValue as string,
+ },
+ type: '',
+ };
+ onChange( changeObject );
+ },
+ } );
+
+ const children = options.map(
+ ( { name, key, __experimentalHint, ...rest } ) => {
+ const withHint = (
+
+ { name }
+
+ { __experimentalHint }
+
+
+ );
+
+ return (
+
+ );
+ }
+ );
+
+ const renderSelectedValueHint = () => {
+ const { value: currentValue } = store.getState();
+
+ const currentHint = options?.find(
+ ( { name } ) => currentValue === name
+ );
+
+ return (
+ <>
+ { currentValue }
+
+ { currentHint?.__experimentalHint }
+
+ >
+ );
+ };
+
+ // translate legacy button sizing
+ const contextSystemValue = useMemo( () => {
+ let selectedSize;
+
+ if (
+ ( __next40pxDefaultSize && size === 'default' ) ||
+ size === '__unstable-large'
+ ) {
+ selectedSize = 'default';
+ } else if ( ! __next40pxDefaultSize && size === 'default' ) {
+ selectedSize = 'compact';
+ } else {
+ selectedSize = size;
+ }
+
+ return {
+ CustomSelectControlButton: { _overrides: { size: selectedSize } },
+ };
+ }, [ __next40pxDefaultSize, size ] );
+
+ const translatedProps = {
+ 'aria-describedby': props.describedBy,
+ children,
+ renderSelectedValue: __experimentalShowSelectedHint
+ ? renderSelectedValueHint
+ : undefined,
+ ...restProps,
+ };
+
+ return (
+
+ <_CustomSelect { ...translatedProps } store={ store } />
+
+ );
+}
+
+export default CustomSelect;
diff --git a/packages/components/src/custom-select-control-v2/stories/index.story.tsx b/packages/components/src/custom-select-control-v2/stories/default.story.tsx
similarity index 62%
rename from packages/components/src/custom-select-control-v2/stories/index.story.tsx
rename to packages/components/src/custom-select-control-v2/stories/default.story.tsx
index 7714edc86157c7..65f00b4792378a 100644
--- a/packages/components/src/custom-select-control-v2/stories/index.story.tsx
+++ b/packages/components/src/custom-select-control-v2/stories/default.story.tsx
@@ -11,10 +11,11 @@ import { useState } from '@wordpress/element';
/**
* Internal dependencies
*/
-import { CustomSelect, CustomSelectItem } from '..';
+import CustomSelect from '../default-component';
+import { CustomSelectItem } from '..';
const meta: Meta< typeof CustomSelect > = {
- title: 'Components (Experimental)/CustomSelectControl v2',
+ title: 'Components (Experimental)/CustomSelectControl v2/Default',
component: CustomSelect,
subcomponents: {
// @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170
@@ -22,7 +23,6 @@ const meta: Meta< typeof CustomSelect > = {
},
argTypes: {
children: { control: { type: null } },
- renderSelectedValue: { control: { type: null } },
value: { control: { type: null } },
},
parameters: {
@@ -30,7 +30,6 @@ const meta: Meta< typeof CustomSelect > = {
actions: { argTypesRegex: '^on.*' },
controls: { expanded: true },
docs: {
- canvas: { sourceState: 'shown' },
source: { excludeDecorators: true },
},
},
@@ -49,15 +48,11 @@ const meta: Meta< typeof CustomSelect > = {
export default meta;
const Template: StoryFn< typeof CustomSelect > = ( props ) => {
- return ;
-};
-
-const ControlledTemplate: StoryFn< typeof CustomSelect > = ( props ) => {
const [ value, setValue ] = useState< string | string[] >();
return (
{
+ onChange={ ( nextValue: string | string[] ) => {
setValue( nextValue );
props.onChange?.( nextValue );
} }
@@ -68,14 +63,18 @@ const ControlledTemplate: StoryFn< typeof CustomSelect > = ( props ) => {
export const Default = Template.bind( {} );
Default.args = {
- label: 'Label',
+ label: 'Label text',
+ defaultValue: 'Select a color...',
children: (
<>
-
- Small
+
+ Blue
-
- Something bigger
+
+ Purple
+
+
+ Pink
>
),
@@ -83,21 +82,13 @@ Default.args = {
/**
* Multiple selection can be enabled by using an array for the `value` and
- * `defaultValue` props. The argument of the `onChange` function will also
+ * `defaultValue` props. The argument type of the `onChange` function will also
* change accordingly.
*/
-export const MultiSelect = Template.bind( {} );
-MultiSelect.args = {
- defaultValue: [ 'lavender', 'tangerine' ],
+export const MultipleSelection = Template.bind( {} );
+MultipleSelection.args = {
label: 'Select Colors',
- renderSelectedValue: ( currentValue: string | string[] ) => {
- if ( ! Array.isArray( currentValue ) ) {
- return currentValue;
- }
- if ( currentValue.length === 0 ) return 'No colors selected';
- if ( currentValue.length === 1 ) return currentValue[ 0 ];
- return `${ currentValue.length } colors selected`;
- },
+ defaultValue: [ 'lavender', 'tangerine' ],
children: (
<>
{ [
@@ -116,32 +107,34 @@ MultiSelect.args = {
),
};
-const renderControlledValue = ( gravatar: string | string[] ) => {
+const renderItem = ( gravatar: string | string[] ) => {
const avatar = `https://gravatar.com/avatar?d=${ gravatar }`;
return (
{ gravatar }
);
};
-export const Controlled = ControlledTemplate.bind( {} );
-Controlled.args = {
+/**
+ * The `renderSelectedValue` prop can be used to customize how the selected value
+ * is rendered in the dropdown trigger.
+ */
+export const CustomSelectedValue = Template.bind( {} );
+CustomSelectedValue.args = {
label: 'Default Gravatars',
- renderSelectedValue: renderControlledValue,
+ renderSelectedValue: renderItem,
children: (
<>
{ [ 'mystery-person', 'identicon', 'wavatar', 'retro' ].map(
( option ) => (
- { renderControlledValue( option ) }
+ { renderItem( option ) }
)
) }
diff --git a/packages/components/src/custom-select-control-v2/stories/legacy.story.tsx b/packages/components/src/custom-select-control-v2/stories/legacy.story.tsx
new file mode 100644
index 00000000000000..9faa285cd72abb
--- /dev/null
+++ b/packages/components/src/custom-select-control-v2/stories/legacy.story.tsx
@@ -0,0 +1,88 @@
+/**
+ * External dependencies
+ */
+import type { Meta, StoryFn } from '@storybook/react';
+
+/**
+ * WordPress dependencies
+ */
+import { useState } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import _LegacyCustomSelect from '../legacy-component';
+import { CustomSelect } from '..';
+
+const meta: Meta< typeof _LegacyCustomSelect > = {
+ title: 'Components (Experimental)/CustomSelectControl v2/Legacy',
+ component: _LegacyCustomSelect,
+ argTypes: {
+ onChange: { control: { type: null } },
+ value: { control: { type: null } },
+ },
+ parameters: {
+ badges: [ 'wip' ],
+ actions: { argTypesRegex: '^on.*' },
+ controls: { expanded: true },
+ docs: {
+ source: { excludeDecorators: true },
+ },
+ },
+ decorators: [
+ ( Story ) => (
+
+
+
+ ),
+ ],
+};
+export default meta;
+
+const Template: StoryFn< typeof _LegacyCustomSelect > = ( props ) => {
+ const [ fontSize, setFontSize ] = useState( props.options[ 0 ] );
+
+ const onChange: React.ComponentProps<
+ typeof _LegacyCustomSelect
+ >[ 'onChange' ] = ( changeObject ) => {
+ setFontSize( changeObject.selectedItem );
+ props.onChange?.( changeObject );
+ };
+
+ return (
+
+ );
+};
+
+export const Default = Template.bind( {} );
+Default.args = {
+ label: 'Label text',
+ options: [
+ {
+ key: 'small',
+ name: 'Small',
+ style: { fontSize: '50%' },
+ __experimentalHint: '50%',
+ },
+ {
+ key: 'normal',
+ name: 'Normal',
+ style: { fontSize: '100%' },
+ className: 'can-apply-custom-class-to-option',
+ },
+ {
+ key: 'large',
+ name: 'Large',
+ style: { fontSize: '200%' },
+ },
+ {
+ key: 'huge',
+ name: 'Huge',
+ style: { fontSize: '300%' },
+ },
+ ],
+};
diff --git a/packages/components/src/custom-select-control-v2/styles.ts b/packages/components/src/custom-select-control-v2/styles.ts
index c04f6ac32e5ffb..f268b75b30c5cd 100644
--- a/packages/components/src/custom-select-control-v2/styles.ts
+++ b/packages/components/src/custom-select-control-v2/styles.ts
@@ -1,18 +1,37 @@
/**
* External dependencies
*/
-import styled from '@emotion/styled';
// eslint-disable-next-line no-restricted-imports
import * as Ariakit from '@ariakit/react';
-
+import { css } from '@emotion/react';
+import styled from '@emotion/styled';
/**
* Internal dependencies
*/
-import { COLORS } from '../utils';
+import { COLORS, CONFIG } from '../utils';
import { space } from '../utils/space';
-import type { CustomSelectProps } from './types';
+import type { CustomSelectButtonProps } from './types';
+
+const ITEM_PADDING = space( 2 );
+
+export const WithHintWrapper = styled.div`
+ display: flex;
+ justify-content: space-between;
+ flex: 1;
+`;
+
+export const SelectedExperimentalHintItem = styled.span`
+ color: ${ COLORS.theme.gray[ 600 ] };
+ margin-inline-start: ${ space( 2 ) };
+`;
-export const CustomSelectLabel = styled( Ariakit.SelectLabel )`
+export const ExperimentalHintItem = styled.span`
+ color: ${ COLORS.theme.gray[ 600 ] };
+ text-align: right;
+ margin-inline-end: ${ space( 1 ) };
+`;
+
+export const SelectLabel = styled( Ariakit.SelectLabel )`
font-size: 11px;
font-weight: 500;
line-height: 1.4;
@@ -20,57 +39,82 @@ export const CustomSelectLabel = styled( Ariakit.SelectLabel )`
margin-bottom: ${ space( 2 ) };
`;
-const inputHeights = {
- default: 40,
- small: 24,
-};
-
-export const CustomSelectButton = styled( Ariakit.Select, {
+export const Select = styled( Ariakit.Select, {
// Do not forward `hasCustomRenderProp` to the underlying Ariakit.Select component
shouldForwardProp: ( prop ) => prop !== 'hasCustomRenderProp',
} )( ( {
size,
hasCustomRenderProp,
}: {
- size: NonNullable< CustomSelectProps[ 'size' ] >;
+ size: NonNullable< CustomSelectButtonProps[ 'size' ] >;
hasCustomRenderProp: boolean;
} ) => {
- const isSmallSize = size === 'small' && ! hasCustomRenderProp;
const heightProperty = hasCustomRenderProp ? 'minHeight' : 'height';
- return {
- display: 'flex',
- justifyContent: 'space-between',
- alignItems: 'center',
- backgroundColor: COLORS.white,
- border: `1px solid ${ COLORS.gray[ 600 ] }`,
- borderRadius: space( 0.5 ),
- cursor: 'pointer',
- width: '100%',
- [ heightProperty ]: `${ inputHeights[ size ] }px`,
- padding: isSmallSize ? space( 2 ) : space( 4 ),
- fontSize: isSmallSize ? '11px' : '13px',
- '&[data-focus-visible]': {
- outlineStyle: 'solid',
- },
- '&[aria-expanded="true"]': {
- outlineStyle: `1.5px solid ${ COLORS.theme.accent }`,
- },
+ const getSize = () => {
+ const sizes = {
+ compact: {
+ [ heightProperty ]: 32,
+ paddingInlineStart: space( 2 ),
+ paddingInlineEnd: space( 1 ),
+ },
+ default: {
+ [ heightProperty ]: 40,
+ paddingInlineStart: space( 4 ),
+ paddingInlineEnd: space( 3 ),
+ },
+ small: {
+ [ heightProperty ]: 24,
+ paddingInlineStart: space( 2 ),
+ paddingInlineEnd: space( 1 ),
+ fontSize: 11,
+ },
+ };
+
+ return sizes[ size ] || sizes.default;
};
+
+ return css`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ background-color: ${ COLORS.theme.background };
+ border: 1px solid ${ COLORS.ui.border };
+ border-radius: 2px;
+ cursor: pointer;
+ font-size: ${ CONFIG.fontSize };
+ width: 100%;
+ &[data-focus-visible] {
+ outline-style: solid;
+ }
+ &[aria-expanded='true'] {
+ outline: 1.5px solid ${ COLORS.theme.accent };
+ }
+ ${ getSize() }
+ `;
} );
-export const CustomSelectPopover = styled( Ariakit.SelectPopover )`
- border-radius: ${ space( 0.5 ) };
- background: ${ COLORS.white };
- border: 1px solid ${ COLORS.gray[ 900 ] };
+export const SelectPopover = styled( Ariakit.SelectPopover )`
+ border-radius: 2px;
+ background: ${ COLORS.theme.background };
+ border: 1px solid ${ COLORS.theme.foreground };
`;
-export const CustomSelectItem = styled( Ariakit.SelectItem )`
+export const SelectItem = styled( Ariakit.SelectItem )`
display: flex;
align-items: center;
justify-content: space-between;
- padding: ${ space( 2 ) };
+ padding: ${ ITEM_PADDING };
+ font-size: ${ CONFIG.fontSize };
+ line-height: 2.15rem; // TODO: Remove this in default but keep for back-compat in legacy
&[data-active-item] {
- background-color: ${ COLORS.gray[ 300 ] };
+ background-color: ${ COLORS.theme.gray[ 300 ] };
}
`;
+
+export const SelectedItemCheck = styled( Ariakit.SelectItemCheck )`
+ display: flex;
+ align-items: center;
+ margin-inline-start: ${ ITEM_PADDING };
+ font-size: 24px; // Size of checkmark icon
+`;
diff --git a/packages/components/src/custom-select-control-v2/test/index.tsx b/packages/components/src/custom-select-control-v2/test/index.tsx
index a707a56e4d724a..cdc93205fba98e 100644
--- a/packages/components/src/custom-select-control-v2/test/index.tsx
+++ b/packages/components/src/custom-select-control-v2/test/index.tsx
@@ -2,7 +2,7 @@
* External dependencies
*/
import { render, screen } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
+import { click, press, type } from '@ariakit/test';
/**
* WordPress dependencies
@@ -13,207 +13,848 @@ import { useState } from '@wordpress/element';
* Internal dependencies
*/
import { CustomSelect, CustomSelectItem } from '..';
-import type { CustomSelectProps } from '../types';
+import type { CustomSelectProps, LegacyCustomSelectProps } from '../types';
+
+const customClass = 'amber-skies';
+
+const legacyProps = {
+ label: 'label!',
+ options: [
+ {
+ key: 'flower1',
+ name: 'violets',
+ },
+ {
+ key: 'flower2',
+ name: 'crimson clover',
+ className: customClass,
+ },
+ {
+ key: 'flower3',
+ name: 'poppy',
+ },
+ {
+ key: 'color1',
+ name: 'amber',
+ className: customClass,
+ },
+ {
+ key: 'color2',
+ name: 'aquamarine',
+ style: {
+ backgroundColor: 'rgb(127, 255, 212)',
+ rotate: '13deg',
+ },
+ },
+ ],
+};
-const ControlledCustomSelect = ( props: CustomSelectProps ) => {
- const [ value, setValue ] = useState< string | string[] >();
+const LegacyControlledCustomSelect = ( {
+ options,
+}: LegacyCustomSelectProps ) => {
+ const [ value, setValue ] = useState( options[ 0 ] );
return (
{
- setValue( nextValue );
- props.onChange?.( nextValue );
- } }
- value={ value }
+ { ...legacyProps }
+ onChange={ ( { selectedItem }: any ) => setValue( selectedItem ) }
+ value={ options.find(
+ ( option: any ) => option.key === value.key
+ ) }
/>
);
};
-describe.each( [
- [ 'uncontrolled', CustomSelect ],
- [ 'controlled', ControlledCustomSelect ],
-] )( 'CustomSelect %s', ( ...modeAndComponent ) => {
- const [ , Component ] = modeAndComponent;
+describe( 'With Legacy Props', () => {
+ describe.each( [
+ [ 'Uncontrolled', CustomSelect ],
+ [ 'Controlled', LegacyControlledCustomSelect ],
+ ] )( '%s', ( ...modeAndComponent ) => {
+ const [ , Component ] = modeAndComponent;
- describe( 'Multiple selection', () => {
- it( 'Should be able to select multiple items when provided an array', async () => {
- const user = userEvent.setup();
- const onChangeMock = jest.fn();
+ it( 'Should replace the initial selection when a new item is selected', async () => {
+ render( );
- // initial selection as defaultValue
- const defaultValues = [
- 'incandescent glow',
- 'ultraviolet morning light',
- ];
+ const currentSelectedItem = screen.getByRole( 'combobox', {
+ expanded: false,
+ } );
- render(
-
- { [
- 'aurora borealis green',
- 'flamingo pink sunrise',
- 'incandescent glow',
- 'rose blush',
- 'ultraviolet morning light',
- ].map( ( item ) => (
-
- { item }
-
- ) ) }
-
+ await click( currentSelectedItem );
+
+ await click(
+ screen.getByRole( 'option', {
+ name: 'crimson clover',
+ } )
);
+ expect( currentSelectedItem ).toHaveTextContent( 'crimson clover' );
+
+ await click( currentSelectedItem );
+
+ await click(
+ screen.getByRole( 'option', {
+ name: 'poppy',
+ } )
+ );
+
+ expect( currentSelectedItem ).toHaveTextContent( 'poppy' );
+ } );
+
+ it( 'Should keep current selection if dropdown is closed without changing selection', async () => {
+ render( );
+
const currentSelectedItem = screen.getByRole( 'combobox', {
expanded: false,
} );
- // ensure more than one item is selected due to defaultValues
+ await press.Tab();
+ await press.Enter();
+ expect(
+ screen.getByRole( 'listbox', {
+ name: 'label!',
+ } )
+ ).toBeVisible();
+
+ await press.Escape();
+ expect(
+ screen.queryByRole( 'listbox', {
+ name: 'label!',
+ } )
+ ).not.toBeInTheDocument();
+
expect( currentSelectedItem ).toHaveTextContent(
- `${ defaultValues.length } items selected`
+ legacyProps.options[ 0 ].name
+ );
+ } );
+
+ it( 'Should apply class only to options that have a className defined', async () => {
+ render( );
+
+ await click(
+ screen.getByRole( 'combobox', {
+ expanded: false,
+ } )
);
- await user.click( currentSelectedItem );
+ // return an array of items _with_ a className added
+ const itemsWithClass = legacyProps.options.filter(
+ ( option ) => option.className !== undefined
+ );
- expect( screen.getByRole( 'listbox' ) ).toHaveAttribute(
- 'aria-multiselectable'
+ // assert against filtered array
+ itemsWithClass.map( ( { name } ) =>
+ expect( screen.getByRole( 'option', { name } ) ).toHaveClass(
+ customClass
+ )
);
- // ensure defaultValues are selected in list of items
- defaultValues.forEach( ( value ) =>
+ // return an array of items _without_ a className added
+ const itemsWithoutClass = legacyProps.options.filter(
+ ( option ) => option.className === undefined
+ );
+
+ // assert against filtered array
+ itemsWithoutClass.map( ( { name } ) =>
expect(
- screen.getByRole( 'option', {
- name: value,
- selected: true,
- } )
- ).toBeVisible()
+ screen.getByRole( 'option', { name } )
+ ).not.toHaveClass( customClass )
);
+ } );
- // name of next selection
- const nextSelectionName = 'rose blush';
+ it( 'Should apply styles only to options that have styles defined', async () => {
+ const customStyles =
+ 'background-color: rgb(127, 255, 212); rotate: 13deg;';
- // element for next selection
- const nextSelection = screen.getByRole( 'option', {
- name: nextSelectionName,
- } );
+ render( );
- // click next selection to add another item to current selection
- await user.click( nextSelection );
+ await click(
+ screen.getByRole( 'combobox', {
+ expanded: false,
+ } )
+ );
- // updated array containing defaultValues + the item just selected
- const updatedSelection = defaultValues.concat( nextSelectionName );
+ // return an array of items _with_ styles added
+ const styledItems = legacyProps.options.filter(
+ ( option ) => option.style !== undefined
+ );
- expect( onChangeMock ).toHaveBeenCalledWith( updatedSelection );
+ // assert against filtered array
+ styledItems.map( ( { name } ) =>
+ expect( screen.getByRole( 'option', { name } ) ).toHaveStyle(
+ customStyles
+ )
+ );
- expect( nextSelection ).toHaveAttribute( 'aria-selected' );
+ // return an array of items _without_ styles added
+ const unstyledItems = legacyProps.options.filter(
+ ( option ) => option.style === undefined
+ );
- // expect increased array length for current selection
- expect( currentSelectedItem ).toHaveTextContent(
- `${ updatedSelection.length } items selected`
+ // assert against filtered array
+ unstyledItems.map( ( { name } ) =>
+ expect(
+ screen.getByRole( 'option', { name } )
+ ).not.toHaveStyle( customStyles )
+ );
+ } );
+
+ it( 'does not show selected hint by default', () => {
+ render(
+
);
+ expect(
+ screen.getByRole( 'combobox', { name: 'Custom select' } )
+ ).not.toHaveTextContent( 'Hint' );
} );
- it( 'Should be able to deselect items when provided an array', async () => {
- const user = userEvent.setup();
+ it( 'shows selected hint when __experimentalShowSelectedHint is set', async () => {
+ render(
+
+ );
- // initial selection as defaultValue
- const defaultValues = [
- 'aurora borealis green',
- 'incandescent glow',
- 'key lime green',
- 'rose blush',
- 'ultraviolet morning light',
- ];
+ expect(
+ screen.getByRole( 'combobox', {
+ expanded: false,
+ } )
+ ).toHaveTextContent( /hint/i );
+ } );
+ it( 'shows selected hint in list of options when added', async () => {
render(
-
- { defaultValues.map( ( item ) => (
-
- { item }
-
- ) ) }
-
+
+ );
+
+ await click(
+ screen.getByRole( 'combobox', { name: 'Custom select' } )
+ );
+
+ expect(
+ screen.getByRole( 'option', { name: /hint/i } )
+ ).toBeVisible();
+ } );
+
+ it( 'Should return object onChange', async () => {
+ const mockOnChange = jest.fn();
+
+ render(
+
);
+ await click(
+ screen.getByRole( 'combobox', {
+ expanded: false,
+ } )
+ );
+
+ expect( mockOnChange ).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining( {
+ inputValue: '',
+ isOpen: false,
+ selectedItem: { key: 'violets', name: 'violets' },
+ type: '',
+ } )
+ );
+
+ await click(
+ screen.getByRole( 'option', {
+ name: 'aquamarine',
+ } )
+ );
+
+ expect( mockOnChange ).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining( {
+ inputValue: '',
+ isOpen: false,
+ selectedItem: expect.objectContaining( {
+ name: 'aquamarine',
+ } ),
+ type: '',
+ } )
+ );
+ } );
+
+ it( 'Should return selectedItem object when specified onChange', async () => {
+ const mockOnChange = jest.fn(
+ ( { selectedItem } ) => selectedItem.key
+ );
+
+ render(
+
+ );
+
+ await press.Tab();
+ expect(
+ screen.getByRole( 'combobox', {
+ expanded: false,
+ } )
+ ).toHaveFocus();
+
+ await type( 'p' );
+ await press.Enter();
+
+ expect( mockOnChange ).toHaveReturnedWith( 'poppy' );
+ } );
+
+ describe( 'Keyboard behavior and accessibility', () => {
+ it( 'Should be able to change selection using keyboard', async () => {
+ render( );
+
+ const currentSelectedItem = screen.getByRole( 'combobox', {
+ expanded: false,
+ } );
+
+ await press.Tab();
+ expect( currentSelectedItem ).toHaveFocus();
+
+ await press.Enter();
+ expect(
+ screen.getByRole( 'listbox', {
+ name: 'label!',
+ } )
+ ).toHaveFocus();
+
+ await press.ArrowDown();
+ await press.Enter();
+
+ expect( currentSelectedItem ).toHaveTextContent(
+ 'crimson clover'
+ );
+ } );
+
+ it( 'Should be able to type characters to select matching options', async () => {
+ render( );
+
+ const currentSelectedItem = screen.getByRole( 'combobox', {
+ expanded: false,
+ } );
+
+ await press.Tab();
+ await press.Enter();
+ expect(
+ screen.getByRole( 'listbox', {
+ name: 'label!',
+ } )
+ ).toHaveFocus();
+
+ await type( 'a' );
+ await press.Enter();
+ expect( currentSelectedItem ).toHaveTextContent( 'amber' );
+ } );
+
+ it( 'Can change selection with a focused input and closed dropdown if typed characters match an option', async () => {
+ render( );
+
+ const currentSelectedItem = screen.getByRole( 'combobox', {
+ expanded: false,
+ } );
+
+ await press.Tab();
+ expect( currentSelectedItem ).toHaveFocus();
+
+ await type( 'aq' );
+
+ expect(
+ screen.queryByRole( 'listbox', {
+ name: 'label!',
+ hidden: true,
+ } )
+ ).not.toBeInTheDocument();
+
+ await press.Enter();
+ expect( currentSelectedItem ).toHaveTextContent( 'aquamarine' );
+ } );
+
+ it( 'Should have correct aria-selected value for selections', async () => {
+ render( );
+
+ const currentSelectedItem = screen.getByRole( 'combobox', {
+ expanded: false,
+ } );
+
+ await click( currentSelectedItem );
+
+ // get all items except for first option
+ const unselectedItems = legacyProps.options.filter(
+ ( { name } ) => name !== legacyProps.options[ 0 ].name
+ );
+
+ // assert that all other items have aria-selected="false"
+ unselectedItems.map( ( { name } ) =>
+ expect(
+ screen.getByRole( 'option', { name, selected: false } )
+ ).toBeVisible()
+ );
+
+ // assert that first item has aria-selected="true"
+ expect(
+ screen.getByRole( 'option', {
+ name: legacyProps.options[ 0 ].name,
+ selected: true,
+ } )
+ ).toBeVisible();
+
+ // change the current selection
+ await click( screen.getByRole( 'option', { name: 'poppy' } ) );
+
+ // click combobox to mount listbox with options again
+ await click( currentSelectedItem );
+
+ // check that first item is has aria-selected="false" after new selection
+ expect(
+ screen.getByRole( 'option', {
+ name: legacyProps.options[ 0 ].name,
+ selected: false,
+ } )
+ ).toBeVisible();
+
+ // check that new selected item now has aria-selected="true"
+ expect(
+ screen.getByRole( 'option', {
+ name: 'poppy',
+ selected: true,
+ } )
+ ).toBeVisible();
+ } );
+ } );
+ } );
+} );
+
+describe( 'static typing', () => {
+ <>
+ { /* @ts-expect-error - when `options` prop is passed, `onChange` should have legacy signature */ }
+ undefined }
+ />
+ undefined }
+ />
+ undefined }
+ >
+ foobar
+
+ { /* @ts-expect-error - when `children` are passed, `onChange` should have new default signature */ }
+ undefined }
+ >
+ foobar
+
+ >;
+} );
+
+const defaultProps = {
+ label: 'label!',
+ children: legacyProps.options.map( ( { name, key } ) => (
+
+ ) ),
+};
+
+const ControlledCustomSelect = ( props: CustomSelectProps ) => {
+ const [ value, setValue ] = useState< string | string[] >();
+ return (
+ {
+ setValue( nextValue );
+ props.onChange?.( nextValue );
+ } }
+ value={ value }
+ />
+ );
+};
+
+describe( 'With Default Props', () => {
+ describe.each( [
+ [ 'Uncontrolled', CustomSelect ],
+ [ 'Controlled', ControlledCustomSelect ],
+ ] )( '%s', ( ...modeAndComponent ) => {
+ const [ , Component ] = modeAndComponent;
+
+ it( 'Should replace the initial selection when a new item is selected', async () => {
+ render( );
+
const currentSelectedItem = screen.getByRole( 'combobox', {
expanded: false,
} );
- await user.click( currentSelectedItem );
-
- // Array containing items to deselect
- const nextSelection = [
- 'aurora borealis green',
- 'rose blush',
- 'incandescent glow',
- ];
-
- // Deselect some items by clicking them to ensure that changes
- // are reflected correctly
- await Promise.all(
- nextSelection.map( async ( value ) => {
- await user.click(
- screen.getByRole( 'option', { name: value } )
- );
- expect(
- screen.getByRole( 'option', {
- name: value,
- selected: false,
- } )
- ).toBeVisible();
+ await click( currentSelectedItem );
+
+ await click(
+ screen.getByRole( 'option', {
+ name: 'crimson clover',
+ } )
+ );
+
+ expect( currentSelectedItem ).toHaveTextContent( 'crimson clover' );
+
+ await click( currentSelectedItem );
+
+ await click(
+ screen.getByRole( 'option', {
+ name: 'poppy',
} )
);
- // expect different array length from defaultValues due to deselecting items
+ expect( currentSelectedItem ).toHaveTextContent( 'poppy' );
+ } );
+
+ it( 'Should keep current selection if dropdown is closed without changing selection', async () => {
+ render( );
+
+ const currentSelectedItem = screen.getByRole( 'combobox', {
+ expanded: false,
+ } );
+
+ await press.Tab();
+ await press.Enter();
+ expect(
+ screen.getByRole( 'listbox', {
+ name: 'label!',
+ } )
+ ).toBeVisible();
+
+ await press.Escape();
+ expect(
+ screen.queryByRole( 'listbox', {
+ name: 'label!',
+ } )
+ ).not.toBeInTheDocument();
+
expect( currentSelectedItem ).toHaveTextContent(
- `${
- defaultValues.length - nextSelection.length
- } items selected`
+ legacyProps.options[ 0 ].name
);
} );
- } );
- it( 'Should allow rendering a custom value when using `renderSelectedValue`', async () => {
- const user = userEvent.setup();
-
- const renderValue = ( value: string | string[] ) => {
- return ;
- };
-
- render(
-
-
- { renderValue( 'april-29' ) }
-
-
- { renderValue( 'july-9' ) }
-
-
- );
-
- const currentSelectedItem = screen.getByRole( 'combobox', {
- expanded: false,
+ describe( 'Keyboard behavior and accessibility', () => {
+ it( 'Should be able to change selection using keyboard', async () => {
+ render( );
+
+ const currentSelectedItem = screen.getByRole( 'combobox', {
+ expanded: false,
+ } );
+
+ await press.Tab();
+ expect( currentSelectedItem ).toHaveFocus();
+
+ await press.Enter();
+ expect(
+ screen.getByRole( 'listbox', {
+ name: 'label!',
+ } )
+ ).toHaveFocus();
+
+ await press.ArrowDown();
+ await press.Enter();
+
+ expect( currentSelectedItem ).toHaveTextContent(
+ 'crimson clover'
+ );
+ } );
+
+ it( 'Should be able to type characters to select matching options', async () => {
+ render( );
+
+ const currentSelectedItem = screen.getByRole( 'combobox', {
+ expanded: false,
+ } );
+
+ await press.Tab();
+ await press.Enter();
+ expect(
+ screen.getByRole( 'listbox', {
+ name: 'label!',
+ } )
+ ).toHaveFocus();
+
+ await type( 'a' );
+ await press.Enter();
+ expect( currentSelectedItem ).toHaveTextContent( 'amber' );
+ } );
+
+ it( 'Can change selection with a focused input and closed dropdown if typed characters match an option', async () => {
+ render( );
+
+ const currentSelectedItem = screen.getByRole( 'combobox', {
+ expanded: false,
+ } );
+
+ await press.Tab();
+ expect( currentSelectedItem ).toHaveFocus();
+
+ await type( 'aq' );
+
+ expect(
+ screen.queryByRole( 'listbox', {
+ name: 'label!',
+ hidden: true,
+ } )
+ ).not.toBeInTheDocument();
+
+ await press.Enter();
+ expect( currentSelectedItem ).toHaveTextContent( 'aquamarine' );
+ } );
+
+ it( 'Should have correct aria-selected value for selections', async () => {
+ render( );
+
+ const currentSelectedItem = screen.getByRole( 'combobox', {
+ expanded: false,
+ } );
+
+ await click( currentSelectedItem );
+
+ // assert that first item has aria-selected="true"
+ expect(
+ screen.getByRole( 'option', {
+ name: 'violets',
+ selected: true,
+ } )
+ ).toBeVisible();
+
+ // change the current selection
+ await click( screen.getByRole( 'option', { name: 'poppy' } ) );
+
+ // click combobox to mount listbox with options again
+ await click( currentSelectedItem );
+
+ // check that first item is has aria-selected="false" after new selection
+ expect(
+ screen.getByRole( 'option', {
+ name: 'violets',
+ selected: false,
+ } )
+ ).toBeVisible();
+
+ // check that new selected item now has aria-selected="true"
+ expect(
+ screen.getByRole( 'option', {
+ name: 'poppy',
+ selected: true,
+ } )
+ ).toBeVisible();
+ } );
+ } );
+
+ describe( 'Multiple selection', () => {
+ it( 'Should be able to select multiple items when provided an array', async () => {
+ const onChangeMock = jest.fn();
+
+ // initial selection as defaultValue
+ const defaultValues = [
+ 'incandescent glow',
+ 'ultraviolet morning light',
+ ];
+
+ render(
+
+ { [
+ 'aurora borealis green',
+ 'flamingo pink sunrise',
+ 'incandescent glow',
+ 'rose blush',
+ 'ultraviolet morning light',
+ ].map( ( item ) => (
+
+ { item }
+
+ ) ) }
+
+ );
+
+ const currentSelectedItem = screen.getByRole( 'combobox', {
+ expanded: false,
+ } );
+
+ // ensure more than one item is selected due to defaultValues
+ expect( currentSelectedItem ).toHaveTextContent(
+ `${ defaultValues.length } items selected`
+ );
+
+ await click( currentSelectedItem );
+
+ expect( screen.getByRole( 'listbox' ) ).toHaveAttribute(
+ 'aria-multiselectable'
+ );
+
+ // ensure defaultValues are selected in list of items
+ defaultValues.forEach( ( value ) =>
+ expect(
+ screen.getByRole( 'option', {
+ name: value,
+ selected: true,
+ } )
+ ).toBeVisible()
+ );
+
+ // name of next selection
+ const nextSelectionName = 'rose blush';
+
+ // element for next selection
+ const nextSelection = screen.getByRole( 'option', {
+ name: nextSelectionName,
+ } );
+
+ // click next selection to add another item to current selection
+ await click( nextSelection );
+
+ // updated array containing defaultValues + the item just selected
+ const updatedSelection =
+ defaultValues.concat( nextSelectionName );
+
+ expect( onChangeMock ).toHaveBeenCalledWith( updatedSelection );
+
+ expect( nextSelection ).toHaveAttribute( 'aria-selected' );
+
+ // expect increased array length for current selection
+ expect( currentSelectedItem ).toHaveTextContent(
+ `${ updatedSelection.length } items selected`
+ );
+ } );
+
+ it( 'Should be able to deselect items when provided an array', async () => {
+ // initial selection as defaultValue
+ const defaultValues = [
+ 'aurora borealis green',
+ 'incandescent glow',
+ 'key lime green',
+ 'rose blush',
+ 'ultraviolet morning light',
+ ];
+
+ render(
+
+ { defaultValues.map( ( item ) => (
+
+ { item }
+
+ ) ) }
+
+ );
+
+ const currentSelectedItem = screen.getByRole( 'combobox', {
+ expanded: false,
+ } );
+
+ await click( currentSelectedItem );
+
+ // Array containing items to deselect
+ const nextSelection = [
+ 'aurora borealis green',
+ 'rose blush',
+ 'incandescent glow',
+ ];
+
+ // Deselect some items by clicking them to ensure that changes
+ // are reflected correctly
+ await Promise.all(
+ nextSelection.map( async ( value ) => {
+ await click(
+ screen.getByRole( 'option', { name: value } )
+ );
+ expect(
+ screen.getByRole( 'option', {
+ name: value,
+ selected: false,
+ } )
+ ).toBeVisible();
+ } )
+ );
+
+ // expect different array length from defaultValues due to deselecting items
+ expect( currentSelectedItem ).toHaveTextContent(
+ `${
+ defaultValues.length - nextSelection.length
+ } items selected`
+ );
+ } );
} );
- expect( currentSelectedItem ).toBeVisible();
+ it( 'Should allow rendering a custom value when using `renderSelectedValue`', async () => {
+ const renderValue = ( value: string | string[] ) => {
+ return ;
+ };
+
+ render(
+
+
+ { renderValue( 'april-29' ) }
+
+
+ { renderValue( 'july-9' ) }
+
+
+ );
+
+ const currentSelectedItem = screen.getByRole( 'combobox', {
+ expanded: false,
+ } );
+
+ expect( currentSelectedItem ).toBeVisible();
- // expect that the initial selection renders an image
- expect( currentSelectedItem ).toContainElement(
- screen.getByRole( 'img', { name: 'april-29' } )
- );
+ // expect that the initial selection renders an image
+ expect( currentSelectedItem ).toContainElement(
+ screen.getByRole( 'img', { name: 'april-29' } )
+ );
- expect(
- screen.queryByRole( 'img', { name: 'july-9' } )
- ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole( 'img', { name: 'july-9' } )
+ ).not.toBeInTheDocument();
- await user.click( currentSelectedItem );
+ await click( currentSelectedItem );
- // expect that the other image is only visible after opening popover with options
- expect( screen.getByRole( 'img', { name: 'july-9' } ) ).toBeVisible();
- expect(
- screen.getByRole( 'option', { name: 'july-9' } )
- ).toBeVisible();
+ // expect that the other image is only visible after opening popover with options
+ expect(
+ screen.getByRole( 'img', { name: 'july-9' } )
+ ).toBeVisible();
+ expect(
+ screen.getByRole( 'option', { name: 'july-9' } )
+ ).toBeVisible();
+ } );
} );
} );
diff --git a/packages/components/src/custom-select-control-v2/types.ts b/packages/components/src/custom-select-control-v2/types.ts
index 2aecc1d4746f5c..b32f3ee2113080 100644
--- a/packages/components/src/custom-select-control-v2/types.ts
+++ b/packages/components/src/custom-select-control-v2/types.ts
@@ -3,30 +3,23 @@
*/
// eslint-disable-next-line no-restricted-imports
import type * as Ariakit from '@ariakit/react';
+import type { FocusEventHandler, MouseEventHandler } from 'react';
-export type CustomSelectContext =
- | {
- /**
- * The store object returned by Ariakit's `useSelectStore` hook.
- */
- store: Ariakit.SelectStore;
- }
- | undefined;
-
-export type CustomSelectProps = {
+export type CustomSelectStore = {
/**
- * The child elements. This should be composed of CustomSelectItem components.
+ * The store object returned by Ariakit's `useSelectStore` hook.
*/
- children: React.ReactNode;
+ store: Ariakit.SelectStore;
+};
+
+export type CustomSelectContext = CustomSelectStore | undefined;
+
+export type CustomSelectButtonProps = {
/**
- * An optional default value for the control. If left `undefined`, the first
- * non-disabled item will be used.
+ * An optional default value for the control when used in uncontrolled mode.
+ * If left `undefined`, the first non-disabled item will be used.
*/
defaultValue?: string | string[];
- /**
- * Label for the control.
- */
- label: string;
/**
* A function that receives the new value of the input.
*/
@@ -42,13 +35,148 @@ export type CustomSelectProps = {
*
* @default 'default'
*/
- size?: 'default' | 'small';
+ size?: 'compact' | 'default' | 'small';
/**
- * Can be used to externally control the value of the control.
+ * The value of the control when used in uncontrolled mode.
*/
value?: string | string[];
};
+export type _CustomSelectProps = {
+ /**
+ * The child elements. This should be composed of `CustomSelectItem` components.
+ */
+ children: React.ReactNode;
+ /**
+ * Used to visually hide the label. It will always be visible to screen readers.
+ *
+ * @default false
+ */
+ hideLabelFromVision?: boolean;
+ /**
+ * Accessible label for the control.
+ */
+ label: string;
+};
+
+export type CustomSelectProps = _CustomSelectProps &
+ Omit< CustomSelectButtonProps, 'size' > & {
+ /**
+ * The size of the control.
+ *
+ * @default 'default'
+ */
+ size?: Exclude< CustomSelectButtonProps[ 'size' ], 'small' >;
+ };
+
+/**
+ * The legacy object structure for the options array.
+ */
+type LegacyOption = {
+ key: string;
+ name: string;
+ style?: React.CSSProperties;
+ className?: string;
+ __experimentalHint?: string;
+};
+
+/**
+ * The legacy object returned from the onChange event.
+ */
+type LegacyOnChangeObject = {
+ highlightedIndex?: number;
+ inputValue?: string;
+ isOpen?: boolean;
+ type?: string;
+ selectedItem: LegacyOption;
+};
+
+export type LegacyCustomSelectProps = {
+ children?: never;
+ /**
+ * Optional classname for the component.
+ */
+ className?: string;
+ /**
+ * Used to visually hide the label. It will always be visible to screen readers.
+ *
+ */
+ hideLabelFromVision?: boolean;
+ /**
+ * Pass in a description that will be shown to screen readers associated with the
+ * select trigger button. If no value is passed, the text "Currently selected:
+ * selectedItem.name" will be used fully translated.
+ */
+ describedBy?: string;
+ /**
+ * Label for the control.
+ */
+ label: string;
+ /**
+ * Function called with the control's internal state changes. The `selectedItem`
+ * property contains the next selected item.
+ */
+ onChange?: ( newValue: LegacyOnChangeObject ) => void;
+ /**
+ * A handler for `onBlur` events.
+ *
+ * @ignore
+ */
+ onBlur?: FocusEventHandler< HTMLButtonElement >;
+ /**
+ * A handler for `onFocus` events.
+ *
+ * @ignore
+ */
+ onFocus?: FocusEventHandler< HTMLButtonElement >;
+ /**
+ * A handler for `onMouseOver` events.
+ *
+ * @ignore
+ */
+ onMouseOut?: MouseEventHandler< HTMLButtonElement >;
+ /**
+ * A handler for `onMouseOut` events.
+ *
+ * @ignore
+ */
+ onMouseOver?: MouseEventHandler< HTMLButtonElement >;
+ /**
+ * The options that can be chosen from.
+ */
+ options: Array< LegacyOption >;
+ /**
+ * The size of the control.
+ *
+ * @default 'default'
+ */
+ size?: 'default' | 'small' | '__unstable-large';
+ /**
+ * Can be used to externally control the value of the control.
+ */
+ value?: LegacyOption;
+ /**
+ * Legacy way to add additional text to the right of each option.
+ *
+ * @default false
+ */
+ __experimentalShowSelectedHint?: boolean;
+ /**
+ * Opt-in prop for an unconstrained width style which became the default in
+ * WordPress 6.5. The prop is no longer needed and can be safely removed.
+ *
+ * @deprecated
+ * @ignore
+ */
+ __nextUnconstrainedWidth?: boolean;
+ /**
+ * Start opting into the larger default height that will become the default size in a future version.
+ *
+ * @default false
+ */
+ __next40pxDefaultSize?: boolean;
+};
+
export type CustomSelectItemProps = {
/**
* The value of the select item. This will be used as the children if