Skip to content

Commit

Permalink
SelectControl: Infer value type from options (#64069)
Browse files Browse the repository at this point in the history
* SelectControl: Infer `value` type from `options`

* Don't infer onChange

* Add static type tests

Co-authored-by: Mikey Binns <hello@mikeybinns.com>

* Rename generic to `V` (for `value`)

* Add changelog

* Simplify return type

---------

Co-authored-by: Mikey Binns <hello@mikeybinns.com>
Co-authored-by: mirka <0mirka00@git.wordpress.org>
Co-authored-by: mikeybinns <mikeybinns@git.wordpress.org>
Co-authored-by: ciampo <mciampini@git.wordpress.org>
Co-authored-by: jsnajdr <jsnajdr@git.wordpress.org>
Co-authored-by: tyxla <tyxla@git.wordpress.org>
Co-authored-by: DaniGuardiola <daniguardiola@git.wordpress.org>
  • Loading branch information
8 people authored Aug 6, 2024
1 parent 0f81a42 commit de33f91
Show file tree
Hide file tree
Showing 6 changed files with 226 additions and 79 deletions.
1 change: 1 addition & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

- `TimeInput`: Expose as subcomponent of `TimePicker` ([#63145](https://github.com/WordPress/gutenberg/pull/63145)).
- `RadioControl`: add support for option help text ([#63751](https://github.com/WordPress/gutenberg/pull/63751)).
- `SelectControl`: Infer `value` type from `options` ([#64069](https://github.com/WordPress/gutenberg/pull/64069)).
- `Guide`: Add `__next40pxDefaultSize` to buttons ([#64181](https://github.com/WordPress/gutenberg/pull/64181)).
- `SelectControl`: Pass through `options` props ([#64211](https://github.com/WordPress/gutenberg/pull/64211)).

Expand Down
35 changes: 20 additions & 15 deletions packages/components/src/date-time/time/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,28 @@ export function TimePicker( {
);
}, [ currentTime ] );

const monthOptions = [
{ value: '01', label: __( 'January' ) },
{ value: '02', label: __( 'February' ) },
{ value: '03', label: __( 'March' ) },
{ value: '04', label: __( 'April' ) },
{ value: '05', label: __( 'May' ) },
{ value: '06', label: __( 'June' ) },
{ value: '07', label: __( 'July' ) },
{ value: '08', label: __( 'August' ) },
{ value: '09', label: __( 'September' ) },
{ value: '10', label: __( 'October' ) },
{ value: '11', label: __( 'November' ) },
{ value: '12', label: __( 'December' ) },
] as const;

const { day, month, year, minutes, hours } = useMemo(
() => ( {
day: format( date, 'dd' ),
month: format( date, 'MM' ),
month: format(
date,
'MM'
) as ( typeof monthOptions )[ number ][ 'value' ],
year: format( date, 'yyyy' ),
minutes: format( date, 'mm' ),
hours: format( date, 'HH' ),
Expand Down Expand Up @@ -146,20 +164,7 @@ export function TimePicker( {
__next40pxDefaultSize
__nextHasNoMarginBottom
value={ month }
options={ [
{ value: '01', label: __( 'January' ) },
{ value: '02', label: __( 'February' ) },
{ value: '03', label: __( 'March' ) },
{ value: '04', label: __( 'April' ) },
{ value: '05', label: __( 'May' ) },
{ value: '06', label: __( 'June' ) },
{ value: '07', label: __( 'July' ) },
{ value: '08', label: __( 'August' ) },
{ value: '09', label: __( 'September' ) },
{ value: '10', label: __( 'October' ) },
{ value: '11', label: __( 'November' ) },
{ value: '12', label: __( 'December' ) },
] }
options={ monthOptions }
onChange={ ( value ) => {
const newDate = setMonth( date, Number( value ) - 1 );
setDate( newDate );
Expand Down
6 changes: 5 additions & 1 deletion packages/components/src/query-controls/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,11 @@ export function QueryControls( {
__next40pxDefaultSize={ __next40pxDefaultSize }
key="query-controls-order-select"
label={ __( 'Order by' ) }
value={ `${ orderBy }/${ order }` }
value={
orderBy === undefined || order === undefined
? undefined
: `${ orderBy }/${ order }`
}
options={ [
{
label: __( 'Newest to oldest' ),
Expand Down
20 changes: 15 additions & 5 deletions packages/components/src/select-control/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ function SelectOptions( {
} );
}

function UnforwardedSelectControl(
props: WordPressComponentProps< SelectControlProps, 'select', false >,
function UnforwardedSelectControl< V extends string >(
props: WordPressComponentProps< SelectControlProps< V >, 'select', false >,
ref: React.ForwardedRef< HTMLSelectElement >
) {
const {
Expand Down Expand Up @@ -82,12 +82,14 @@ function UnforwardedSelectControl(
const selectedOptions = Array.from( event.target.options ).filter(
( { selected } ) => selected
);
const newValues = selectedOptions.map( ( { value } ) => value );
const newValues = selectedOptions.map(
( { value } ) => value as V
);
props.onChange?.( newValues, { event } );
return;
}

props.onChange?.( event.target.value, { event } );
props.onChange?.( event.target.value as V, { event } );
};

const classes = clsx( 'components-select-control', className );
Expand Down Expand Up @@ -164,6 +166,14 @@ function UnforwardedSelectControl(
* };
* ```
*/
export const SelectControl = forwardRef( UnforwardedSelectControl );
export const SelectControl = forwardRef( UnforwardedSelectControl ) as <
V extends string,
>(
props: WordPressComponentProps<
SelectControlProps< V >,
'select',
false
> & { ref?: React.Ref< HTMLSelectElement > }
) => React.JSX.Element | null;

export default SelectControl;
125 changes: 125 additions & 0 deletions packages/components/src/select-control/test/select-control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,4 +100,129 @@ describe( 'SelectControl', () => {
screen.getByRole( 'option', { name: 'Aria label' } )
).toBeInTheDocument();
} );

/* eslint-disable jest/expect-expect */
describe( 'static typing', () => {
describe( 'single', () => {
it( 'should infer the value type from available `options`, but not the `value` or `onChange` prop', () => {
const onChange: ( value: 'foo' | 'bar' ) => void = () => {};

<SelectControl
value="narrow"
options={ [
{
value: 'narrow',
label: 'Narrow',
},
{
value: 'value',
label: 'Value',
},
] }
// @ts-expect-error onChange type is not compatible with inferred value type
onChange={ onChange }
/>;

<SelectControl
// @ts-expect-error "string" is not "narrow" or "value"
value="string"
options={ [
{
value: 'narrow',
label: 'Narrow',
},
{
value: 'value',
label: 'Value',
},
] }
// @ts-expect-error "string" is not "narrow" or "value"
onChange={ ( value ) => value === 'string' }
/>;
} );

it( 'should accept an explicit type argument', () => {
<SelectControl< 'narrow' | 'value' >
// @ts-expect-error "string" is not "narrow" or "value"
value="string"
options={ [
{
value: 'narrow',
label: 'Narrow',
},
{
// @ts-expect-error "string" is not "narrow" or "value"
value: 'string',
label: 'String',
},
] }
/>;
} );
} );

describe( 'multiple', () => {
it( 'should infer the value type from available `options`, but not the `value` or `onChange` prop', () => {
const onChange: (
value: ( 'foo' | 'bar' )[]
) => void = () => {};

<SelectControl
multiple
value={ [ 'narrow' ] }
options={ [
{
value: 'narrow',
label: 'Narrow',
},
{
value: 'value',
label: 'Value',
},
] }
// @ts-expect-error onChange type is not compatible with inferred value type
onChange={ onChange }
/>;

<SelectControl
multiple
// @ts-expect-error "string" is not "narrow" or "value"
value={ [ 'string' ] }
options={ [
{
value: 'narrow',
label: 'Narrow',
},
{
value: 'value',
label: 'Value',
},
] }
onChange={ ( value ) =>
// @ts-expect-error "string" is not "narrow" or "value"
value.forEach( ( v ) => v === 'string' )
}
/>;
} );

it( 'should accept an explicit type argument', () => {
<SelectControl< 'narrow' | 'value' >
multiple
// @ts-expect-error "string" is not "narrow" or "value"
value={ [ 'string' ] }
options={ [
{
value: 'narrow',
label: 'Narrow',
},
{
// @ts-expect-error "string" is not "narrow" or "value"
value: 'string',
label: 'String',
},
] }
/>;
} );
} );
} );
/* eslint-enable jest/expect-expect */
} );
118 changes: 60 additions & 58 deletions packages/components/src/select-control/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type { ChangeEvent, ReactNode } from 'react';
import type { InputBaseProps } from '../input-control/types';
import type { BaseControlProps } from '../base-control/types';

type SelectControlBaseProps = Pick<
type SelectControlBaseProps< V extends string > = Pick<
InputBaseProps,
| '__next36pxDefaultSize'
| '__next40pxDefaultSize'
Expand All @@ -27,7 +27,7 @@ type SelectControlBaseProps = Pick<
* each with a `label` and `value` property, as well as any other
* `<option>` attributes.
*/
options?: ( {
options?: readonly ( {
/**
* The label to be shown to the user.
*/
Expand All @@ -36,7 +36,7 @@ type SelectControlBaseProps = Pick<
* The internal value used to choose the selected value.
* This is also the value passed to `onChange` when the option is selected.
*/
value: string;
value: V;
} & Omit<
React.OptionHTMLAttributes< HTMLOptionElement >,
'label' | 'value'
Expand All @@ -54,60 +54,62 @@ type SelectControlBaseProps = Pick<
variant?: 'default' | 'minimal';
};

export type SelectControlSingleSelectionProps = SelectControlBaseProps & {
/**
* If this property is added, multiple values can be selected. The `value` passed should be an array.
*
* In most cases, it is preferable to use the `FormTokenField` or `CheckboxControl` components instead.
*
* @default false
*/
multiple?: false;
/**
* The value of the selected option.
*
* If `multiple` is true, the `value` should be an array with the values of the selected options.
*/
value?: string;
/**
* A function that receives the value of the new option that is being selected as input.
*
* If `multiple` is `true`, the value received is an array of the selected value.
* Otherwise, the value received is a single value with the new selected value.
*/
onChange?: (
value: string,
extra?: { event?: ChangeEvent< HTMLSelectElement > }
) => void;
};
export type SelectControlSingleSelectionProps< V extends string = string > =
SelectControlBaseProps< V > & {
/**
* If this property is added, multiple values can be selected. The `value` passed should be an array.
*
* In most cases, it is preferable to use the `FormTokenField` or `CheckboxControl` components instead.
*
* @default false
*/
multiple?: false;
/**
* The value of the selected option.
*
* If `multiple` is true, the `value` should be an array with the values of the selected options.
*/
value?: NoInfer< V >;
/**
* A function that receives the value of the new option that is being selected as input.
*
* If `multiple` is `true`, the value received is an array of the selected value.
* Otherwise, the value received is a single value with the new selected value.
*/
onChange?: (
value: NoInfer< V >,
extra?: { event?: ChangeEvent< HTMLSelectElement > }
) => void;
};

export type SelectControlMultipleSelectionProps = SelectControlBaseProps & {
/**
* If this property is added, multiple values can be selected. The `value` passed should be an array.
*
* In most cases, it is preferable to use the `FormTokenField` or `CheckboxControl` components instead.
*
* @default false
*/
multiple: true;
/**
* The value of the selected option.
*
* If `multiple` is true, the `value` should be an array with the values of the selected options.
*/
value?: string[];
/**
* A function that receives the value of the new option that is being selected as input.
*
* If `multiple` is `true`, the value received is an array of the selected value.
* Otherwise, the value received is a single value with the new selected value.
*/
onChange?: (
value: string[],
extra?: { event?: ChangeEvent< HTMLSelectElement > }
) => void;
};
export type SelectControlMultipleSelectionProps< V extends string > =
SelectControlBaseProps< V > & {
/**
* If this property is added, multiple values can be selected. The `value` passed should be an array.
*
* In most cases, it is preferable to use the `FormTokenField` or `CheckboxControl` components instead.
*
* @default false
*/
multiple: true;
/**
* The value of the selected option.
*
* If `multiple` is true, the `value` should be an array with the values of the selected options.
*/
value?: NoInfer< V >[];
/**
* A function that receives the value of the new option that is being selected as input.
*
* If `multiple` is `true`, the value received is an array of the selected value.
* Otherwise, the value received is a single value with the new selected value.
*/
onChange?: (
value: NoInfer< V >[],
extra?: { event?: ChangeEvent< HTMLSelectElement > }
) => void;
};

export type SelectControlProps =
| SelectControlSingleSelectionProps
| SelectControlMultipleSelectionProps;
export type SelectControlProps< V extends string = string > =
| SelectControlSingleSelectionProps< V >
| SelectControlMultipleSelectionProps< V >;

0 comments on commit de33f91

Please sign in to comment.