Skip to content

Commit

Permalink
Try Ariakit Select for new CustomSelectControl component (#55790)
Browse files Browse the repository at this point in the history
* Create base component using ariakit

* Simplify story example

* Add options for children and value with stories

* Update types

* Add uncontrolled story and adapt value

* Remove duplicate import

* Add size prop

* Changes from pairing session with ciampo

* Rename and cleanup

* Update styles

* Add context

* Add README

* Update manifest

* Update types and move context provider

* Add back types for multi-selection

* Remove file for moved component

* Add translation for placeholder

* Add check and cleanup styles

* Add multiselect example

* Require type children for CustomSelectItem

* Add small size and update sizing logic

* Update stories with ciampo’s suggested changes

* Update styles to match legacy CustomSelectControl

* Cleanup based on PR feedback

* Children as optional

* Incorporate ciampo’s suggestions to refine styles

* Make value required for CustomSelectItem and fix naming in comment

* Update changelog

* Refine styles and add styling to active item

* Only render label if defined

* Cleanup story

* Add suggestions to WIP README and types

* Make label required

* Improve logic for default rendered value

* Update stories
  • Loading branch information
brookewp authored Nov 23, 2023
1 parent 352ab99 commit 427fa76
Show file tree
Hide file tree
Showing 7 changed files with 470 additions and 0 deletions.
6 changes: 6 additions & 0 deletions docs/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -785,6 +785,12 @@
"markdown_source": "../packages/components/src/confirm-dialog/README.md",
"parent": "components"
},
{
"title": "CustomSelectControlV2",
"slug": "custom-select-control-v2",
"markdown_source": "../packages/components/src/custom-select-control-v2/README.md",
"parent": "components"
},
{
"title": "CustomSelectControl",
"slug": "custom-select-control",
Expand Down
4 changes: 4 additions & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@

- `Slot`: add `style` prop to `bubblesVirtually` version ([#56428](https://github.com/WordPress/gutenberg/pull/56428))

### Internal

- Introduce experimental new version of `CustomSelectControl` based on `ariakit` ([#55790](https://github.com/WordPress/gutenberg/pull/55790))

## 25.12.0 (2023-11-16)

### Bug Fix
Expand Down
73 changes: 73 additions & 0 deletions packages/components/src/custom-select-control-v2/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<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.

#### Props

The component accepts the following props:

##### `children`: `React.ReactNode`

The child elements. This should be composed of CustomSelect.Item components.

- Required: yes

##### `defaultValue`: `string`

An optional default value for the control. If left `undefined`, the first non-disabled item will be used.

- Required: no

##### `label`: `string`

Label for the control.

- Required: yes

##### `onChange`: `( newValue: string ) => void`

A function that receives the new value of the input.

- Required: no

##### `renderSelectedValue`: `( selectValue: string ) => React.ReactNode`

Can be used to render select UI with custom styled values.

- Required: no

##### `size`: `'default' | 'large'`

The size of the control.

- Required: no

##### `value`: `string`

Can be used to externally control the value of the control.

- Required: no

### `CustomSelectItem`

Used to render a select item.

#### Props

The component accepts the following props:

##### `value`: `string`

The value of the select item. This will be used as the children if children are left `undefined`.

- Required: yes

##### `children`: `React.ReactNode`

The children to display for each select item. The `value` will be used if left `undefined`.

- Required: no
99 changes: 99 additions & 0 deletions packages/components/src/custom-select-control-v2/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* 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';

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( props: CustomSelectProps ) {
const {
children,
defaultValue,
label,
onChange,
size = 'default',
value,
renderSelectedValue = defaultRenderSelectedValue,
} = props;

const store = Ariakit.useSelectStore( {
setValue: ( nextValue ) => onChange?.( nextValue ),
defaultValue,
value,
} );

const { value: currentValue } = store.useState();

return (
<>
<Styled.CustomSelectLabel store={ store }>
{ label }
</Styled.CustomSelectLabel>
<Styled.CustomSelectButton
size={ size }
hasCustomRenderProp={ !! renderSelectedValue }
store={ store }
>
{ renderSelectedValue( currentValue ) }
<Ariakit.SelectArrow />
</Styled.CustomSelectButton>
<Styled.CustomSelectPopover gutter={ 12 } store={ store } sameWidth>
<CustomSelectContext.Provider value={ { store } }>
{ children }
</CustomSelectContext.Provider>
</Styled.CustomSelectPopover>
</>
);
}

export function CustomSelectItem( {
children,
...props
}: CustomSelectItemProps ) {
const customSelectContext = useContext( CustomSelectContext );
return (
<Styled.CustomSelectItem
store={ customSelectContext?.store }
{ ...props }
>
{ children ?? props.value }
<Ariakit.SelectItemCheck />
</Styled.CustomSelectItem>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* External dependencies
*/
import type { Meta, StoryFn } from '@storybook/react';

/**
* WordPress dependencies
*/
import { useState } from '@wordpress/element';

/**
* Internal dependencies
*/
import { CustomSelect, CustomSelectItem } from '..';

const meta: Meta< typeof CustomSelect > = {
title: 'Components (Experimental)/CustomSelectControl v2',
component: CustomSelect,
subcomponents: {
// @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170
CustomSelectItem,
},
argTypes: {
children: { control: { type: null } },
renderSelectedValue: { control: { type: null } },
value: { control: { type: null } },
},
parameters: {
actions: { argTypesRegex: '^on.*' },
controls: { expanded: true },
docs: {
canvas: { sourceState: 'shown' },
source: { excludeDecorators: true },
},
},
decorators: [
( Story ) => (
<div
style={ {
minHeight: '150px',
} }
>
<Story />
</div>
),
],
};
export default meta;

const Template: StoryFn< typeof CustomSelect > = ( props ) => {
return <CustomSelect { ...props } />;
};

const ControlledTemplate: StoryFn< typeof CustomSelect > = ( props ) => {
const [ value, setValue ] = useState< string | string[] >();
return (
<CustomSelect
{ ...props }
onChange={ ( nextValue ) => {
setValue( nextValue );
props.onChange?.( nextValue );
} }
value={ value }
/>
);
};

export const Default = Template.bind( {} );
Default.args = {
label: 'Label',
children: (
<>
<CustomSelectItem value="Small">
<span style={ { fontSize: '75%' } }>Small</span>
</CustomSelectItem>
<CustomSelectItem value="Something bigger">
<span style={ { fontSize: '200%' } }>Something bigger</span>
</CustomSelectItem>
</>
),
};

/**
* 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.
*/
export const MultiSelect = Template.bind( {} );
MultiSelect.args = {
defaultValue: [ 'lavender', 'tangerine' ],
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`;
},
children: (
<>
{ [
'amber',
'aquamarine',
'flamingo pink',
'lavender',
'maroon',
'tangerine',
].map( ( item ) => (
<CustomSelectItem key={ item } value={ item }>
{ item }
</CustomSelectItem>
) ) }
</>
),
};

const renderControlledValue = ( gravatar: string | string[] ) => {
const avatar = `https://gravatar.com/avatar?d=${ gravatar }`;
return (
<div style={ { display: 'flex', alignItems: 'center' } }>
<img
style={ { maxHeight: '75px', marginRight: '10px' } }
key={ avatar }
src={ avatar }
alt=""
aria-hidden="true"
/>
<span>{ gravatar }</span>
</div>
);
};

export const Controlled = ControlledTemplate.bind( {} );
Controlled.args = {
label: 'Default Gravatars',
renderSelectedValue: renderControlledValue,
children: (
<>
{ [ 'mystery-person', 'identicon', 'wavatar', 'retro' ].map(
( option ) => (
<CustomSelectItem key={ option } value={ option }>
{ renderControlledValue( option ) }
</CustomSelectItem>
)
) }
</>
),
};
Loading

0 comments on commit 427fa76

Please sign in to comment.