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