Skip to content

Commit

Permalink
CustomSelect: Adapt component for legacy props (#57902)
Browse files Browse the repository at this point in the history
* Move components to own files

* Add legacy adapter

* Add legacy tests

* Update naming and remove unnecessary tests

* Add legacy props to adapter

* Create new component to forward store

* Create legacy component

* Remove useDeprecatedProps hook and update adapter

* Separate stories

* Update stories and types

* Convert function into variable instead

* Update legacy changeObject to match existing properties

* Add tests for onChange function

* add rest of legacy props

* Update sizing

* Memoize selected render value

* Remove deprecated prop to fix test failures

* Add `unmountOnHide` and require `defaultValue` as a result

See: ariakit/ariakit#3374 (comment)

* Update styling for experimental hint

* Connect CustomSelectButton to context system for legacy sizing

* Update sizing logic and types

* Fix styling to match legacy including hints

* Clean up props

* Omit ‘onChange’ from WordPressComponentProps to prevent conflict

* Update checkmark icon

* Update select arrow

* Update types

* Add static typing test

* Update story for better manual testing

* Control mount state for legacy keyboard behaviour

* Remove export that is no longer needed

* Update legacy onChange type

* Update tests

* Update naming

* Try mounting on first render to avoid required defaultValue

* Add WordPressComponentProps to default export

* Update types

* Replace RTL/userEvent with ariakit/test

* Remove unmountOnHide and related logic for first iteration

* Update docs

* Update naming

* Merge new tests and update to ariakit/test

* Fix typo in readme

* Legacy: Clean up stories

* Default: Clean up stories

* Add todo comment about BaseControl

* Fix styles

* Rename styled components for consistency

* Fix typo in readme

* Rename for clarity

* Update changelog

---------

Co-authored-by: brookewp <brookemk@git.wordpress.org>
Co-authored-by: mirka <0mirka00@git.wordpress.org>
Co-authored-by: ciampo <mciampini@git.wordpress.org>
Co-authored-by: diegohaz <hazdiego@git.wordpress.org>
  • Loading branch information
5 people authored Feb 10, 2024
1 parent 19622fa commit 5939c41
Show file tree
Hide file tree
Showing 13 changed files with 1,568 additions and 347 deletions.
4 changes: 4 additions & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
104 changes: 97 additions & 7 deletions packages/components/src/custom-select-control-v2/README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,93 @@
# CustomSelect

<div class="callout callout-alert">
This feature is still experimental. “Experimental” means this is an early implementation subject to drastic and breaking changes.
</div>

### `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 = () => (
<CustomSelect label="Colors">
<CustomSelectItem value="Blue">
{ /* The `defaultValue` since it wasn't defined */ }
<span style={ { color: 'blue' } }>Blue</span>
</CustomSelectItem>
<CustomSelectItem value="Purple">
<span style={ { color: 'purple' } }>Purple</span>
</CustomSelectItem>
<CustomSelectItem value="Pink">
<span style={ { color: 'deeppink' } }>Pink</span>
</CustomSelectItem>
</CustomSelect>
);
```

#### 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 (
<CustomSelect
{ ...props }
onChange={ ( nextValue ) => {
setValue( nextValue );
props.onChange?.( nextValue );
} }
value={ value }
>
{ [ 'blue', 'purple', 'pink' ].map( ( option ) => (
<CustomSelectItem key={ option } value={ option }>
{ renderControlledValue( option ) }
</CustomSelectItem>
) ) }
</CustomSelect>
);
};
```
#### 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 = () => (
<CustomSelect defaultValue={ [ 'blue', 'pink' ] } label="Colors">
{ [ 'blue', 'purple', 'pink' ].map( ( item ) => (
<CustomSelectItem key={ item } value={ item }>
{ item }
</CustomSelectItem>
) ) }
</CustomSelect>
);
```
### 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:
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<Styled.SelectItem store={ customSelectContext?.store } { ...props }>
{ children ?? props.value }
<Styled.SelectedItemCheck>
<Icon icon={ check } />
</Styled.SelectedItemCheck>
</Styled.SelectItem>
);
}

export default CustomSelectItem;
122 changes: 122 additions & 0 deletions packages/components/src/custom-select-control-v2/custom-select.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Styled.Select
{ ...restProps }
size={ size }
hasCustomRenderProp={ !! renderSelectedValue }
store={ store }
// to match legacy behavior where using arrow keys
// move selection rather than open the popover
showOnKeyDown={ false }
>
<div>{ computedRenderSelectedValue( currentValue ) }</div>
<Icon icon={ chevronDown } size={ 18 } />
</Styled.Select>
);
};

const CustomSelectButton = contextConnectWithoutRef(
UnconnectedCustomSelectButton,
'CustomSelectControlButton'
);

function _CustomSelect( props: _CustomSelectProps & CustomSelectStore ) {
const {
children,
hideLabelFromVision = false,
label,
store,
...restProps
} = props;

return (
<>
{ hideLabelFromVision ? ( // TODO: Replace with BaseControl
<VisuallyHidden as="label">{ label }</VisuallyHidden>
) : (
<Styled.SelectLabel store={ store }>
{ label }
</Styled.SelectLabel>
) }
<CustomSelectButton { ...restProps } store={ store } />
<Styled.SelectPopover gutter={ 12 } store={ store } sameWidth>
<CustomSelectContext.Provider value={ { store } }>
{ children }
</CustomSelectContext.Provider>
</Styled.SelectPopover>
</>
);
}

export default _CustomSelect;
Original file line number Diff line number Diff line change
@@ -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;
Loading

0 comments on commit 5939c41

Please sign in to comment.