Skip to content

Commit

Permalink
ComboboxControl and FormTokenField: enhance components with custom re…
Browse files Browse the repository at this point in the history
…nder callback for options (#42597)

* Add renderSuggestion prop to SuggestionsList component

* Add renderOption prop to ComboboxControl component

* Add story to ComboboxControl component

* Improve renderSuggestion and renderOption signature

* Add renderSuggestion prop to FormTokenField

* Add props to README

* Update changelog

* Fix fatal error

* Fix backward compatibility

* Update packages/components/src/combobox-control/index.js

Co-authored-by: Renzo Canepa <[email protected]>

* Add __experimental prefix to render callbacks

* Update packages/components/src/form-token-field/types.ts

Co-authored-by: Lena Morita <[email protected]>

* Improve and clean up story

* Update docs

* Update form-token-field story

* Improve docs

Co-authored-by: Renzo Canepa <[email protected]>
Co-authored-by: Lena Morita <[email protected]>
  • Loading branch information
3 people authored Aug 10, 2022
1 parent d908473 commit 8568104
Show file tree
Hide file tree
Showing 9 changed files with 116 additions and 20 deletions.
1 change: 1 addition & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
- `BorderControl`: Render dropdown as prefix within its `UnitControl` ([#42212](https://github.com/WordPress/gutenberg/pull/42212/))
- `UnitControl`: Update prop types to allow ReactNode as prefix ([#42212](https://github.com/WordPress/gutenberg/pull/42212/))
- `ToolsPanel`: Updated README with panel layout information and more expansive usage example ([#42615](https://github.com/WordPress/gutenberg/pull/42615)).
- `ComboboxControl`, `FormTokenField`: Add custom render callback for options in suggestions list ([#42597](https://github.com/WordPress/gutenberg/pull/42597/)).

### Internal

Expand Down
7 changes: 7 additions & 0 deletions packages/components/src/combobox-control/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,13 @@ The current value of the input.
- Type: `mixed`
- Required: Yes

#### __experimentalRenderItem

Custom renderer invoked for each option in the suggestion list. The render prop receives as its argument an object containing, under the `item` key, the single option's data (directly from the array of data passed to the `options` prop).

- Type: `Function` - `( args: { item: object } ) => ReactNode`
- Required: No

## Related components

- Like this component, but without a search input, the `CustomSelectControl` component.
Expand Down
4 changes: 4 additions & 0 deletions packages/components/src/combobox-control/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ function ComboboxControl( {
messages = {
selected: __( 'Item selected.' ),
},
__experimentalRenderItem,
} ) {
const [ value, setValue ] = useControlledValue( {
value: valueProp,
Expand Down Expand Up @@ -285,6 +286,9 @@ function ComboboxControl( {
onHover={ setSelectedSuggestion }
onSelect={ onSuggestionSelected }
scrollIntoView
__experimentalRenderItem={
__experimentalRenderItem
}
/>
) }
</div>
Expand Down
54 changes: 49 additions & 5 deletions packages/components/src/combobox-control/stories/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ const mapCountryOption = ( country ) => ( {

const countryOptions = countries.map( mapCountryOption );

function CountryCodeComboboxControl( args ) {
function Template( args ) {
const [ value, setValue ] = useState( null );

return (
Expand All @@ -275,15 +275,59 @@ function CountryCodeComboboxControl( args ) {
{ ...args }
value={ value }
onChange={ setValue }
label="Select a country"
options={ countryOptions }
/>
<p>Value: { value }</p>
</>
);
}
export const _default = CountryCodeComboboxControl.bind( {} );
_default.args = {
export const Default = Template.bind( {} );
Default.args = {
__next36pxDefaultSize: false,
allowReset: false,
label: 'Select a country',
options: countryOptions,
};

const authorOptions = [
{
value: 'parsley',
label: 'Parsley Montana',
age: 48,
country: 'Germany',
},
{
value: 'cabbage',
label: 'Cabbage New York',
age: 44,
country: 'France',
},
{
value: 'jake',
label: 'Jake Weary',
age: 41,
country: 'United Kingdom',
},
];

/**
* The rendered output of each suggestion can be customized by passing a
* render function to the `__experimentalRenderItem` prop. (This is still an experimental feature
* and is subject to change.)
*/
export const WithCustomRenderItem = Template.bind( {} );
WithCustomRenderItem.args = {
...Default.args,
label: 'Select an author',
options: authorOptions,
__experimentalRenderItem: ( { item } ) => {
const { label, age, country } = item;
return (
<div>
<div style={ { marginBottom: '0.2rem' } }>{ label }</div>
<small>
Age: { age }, Country: { country }
</small>
</div>
);
},
};
3 changes: 2 additions & 1 deletion packages/components/src/form-token-field/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ The `value` property is handled in a manner similar to controlled form component
- `removed` - The user removed an existing token.
- `remove` - The user focused the button to remove the token.
- `__experimentalInvalid` - The user tried to add a token that didn't pass the validation.
- `__experimentalRenderItem` - Custom renderer invoked for each option in the suggestion list. The render prop receives as its argument an object containing, under the `item` key, the single option's data (directly from the array of data passed to the `options` prop).
- `__experimentalExpandOnFocus` - If true, the suggestions list will be always expanded when the input field has the focus.
- `__experimentalShowHowTo` - If false, the text on how to use the select (ie: _Separate with commas or the Enter key._) will be hidden.
- `__experimentalValidateInput` - If passed, all introduced values will be validated before being added as tokens.
Expand All @@ -78,7 +79,7 @@ const continents = [
const MyFormTokenField = () => {
const [ selectedContinents, setSelectedContinents ] = useState( [] );

return(
return (
<FormTokenField
value={ selectedContinents }
suggestions={ continents }
Expand Down
2 changes: 2 additions & 0 deletions packages/components/src/form-token-field/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export function FormTokenField( props: FormTokenFieldProps ) {
remove: __( 'Remove item' ),
__experimentalInvalid: __( 'Invalid item' ),
},
__experimentalRenderItem,
__experimentalExpandOnFocus = false,
__experimentalValidateInput = () => true,
__experimentalShowHowTo = true,
Expand Down Expand Up @@ -693,6 +694,7 @@ export function FormTokenField( props: FormTokenFieldProps ) {
scrollIntoView={ selectedSuggestionScroll }
onHover={ onSuggestionHovered }
onSelect={ onSuggestionSelected }
__experimentalRenderItem={ __experimentalRenderItem }
/>
) }
</div>
Expand Down
14 changes: 14 additions & 0 deletions packages/components/src/form-token-field/stories/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,17 @@ Async.args = {
label: 'Type a continent',
suggestions: continents,
};

/**
* The rendered output of each suggestion can be customized by passing a
* render function to the `__experimentalRenderItem` prop. (This is still an experimental feature
* and is subject to change.)
*/
export const WithCustomRenderItem: ComponentStory< typeof FormTokenField > =
DefaultTemplate.bind( {} );
WithCustomRenderItem.args = {
...Default.args,
__experimentalRenderItem: ( { item } ) => (
<div>{ `${ item } — a nice place to visit` }</div>
),
};
33 changes: 21 additions & 12 deletions packages/components/src/form-token-field/suggestions-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import { map } from 'lodash';
import scrollView from 'dom-scroll-into-view';
import classnames from 'classnames';
import type { MouseEventHandler } from 'react';
import type { MouseEventHandler, ReactNode } from 'react';

/**
* WordPress dependencies
Expand All @@ -31,6 +31,7 @@ export function SuggestionsList< T extends string | { value: string } >( {
suggestions = [],
displayTransform,
instanceId,
__experimentalRenderItem,
}: SuggestionsListProps< T > ) {
const [ scrollingIntoView, setScrollingIntoView ] = useState( false );

Expand Down Expand Up @@ -122,6 +123,24 @@ export function SuggestionsList< T extends string | { value: string } >( {
}
);

let output: ReactNode;

if ( typeof __experimentalRenderItem === 'function' ) {
output = __experimentalRenderItem( { item: suggestion } );
} else if ( matchText ) {
output = (
<span aria-label={ displayTransform( suggestion ) }>
{ matchText.suggestionBeforeMatch }
<strong className="components-form-token-field__suggestion-match">
{ matchText.suggestionMatch }
</strong>
{ matchText.suggestionAfterMatch }
</span>
);
} else {
output = displayTransform( suggestion );
}

/* eslint-disable jsx-a11y/click-events-have-key-events */
return (
<li
Expand All @@ -139,17 +158,7 @@ export function SuggestionsList< T extends string | { value: string } >( {
onMouseEnter={ handleHover( suggestion ) }
aria-selected={ index === selectedIndex }
>
{ matchText ? (
<span aria-label={ displayTransform( suggestion ) }>
{ matchText.suggestionBeforeMatch }
<strong className="components-form-token-field__suggestion-match">
{ matchText.suggestionMatch }
</strong>
{ matchText.suggestionAfterMatch }
</span>
) : (
displayTransform( suggestion )
) }
{ output }
</li>
);
/* eslint-enable jsx-a11y/click-events-have-key-events */
Expand Down
18 changes: 16 additions & 2 deletions packages/components/src/form-token-field/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
/**
* External dependencies
*/
import type { ComponentPropsWithRef, MouseEventHandler } from 'react';
import type {
ComponentPropsWithRef,
MouseEventHandler,
ReactNode,
} from 'react';

type Messages = {
/**
Expand Down Expand Up @@ -154,9 +158,18 @@ export interface FormTokenFieldProps
* @default false
*/
__next36pxDefaultSize?: boolean;
/**
* Custom renderer for suggestions.
*/
__experimentalRenderItem?: ( args: { item: string } ) => ReactNode;
}

export interface SuggestionsListProps< T = string | { value: string } > {
/**
* `T` can be either a `string` or an object which must have a `value` prop as a string.
*/
export interface SuggestionsListProps<
T = string | ( Record< string, unknown > & { value: string } )
> {
selectedIndex: number;
scrollIntoView: boolean;
match: T;
Expand All @@ -165,6 +178,7 @@ export interface SuggestionsListProps< T = string | { value: string } > {
suggestions: T[];
displayTransform: ( value: T ) => string;
instanceId: string | number;
__experimentalRenderItem?: ( args: { item: T } ) => ReactNode;
}

export interface TokenProps extends TokenItem {
Expand Down

0 comments on commit 8568104

Please sign in to comment.