-
Notifications
You must be signed in to change notification settings - Fork 4.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
SelectControl: Infer value
type from options
#64069
Changes from 1 commit
2ca2f43
34ca3a0
ad20004
2ff5b37
ee8e353
0770b14
2f195c2
7488d9a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 }` | ||
} | ||
Comment on lines
+88
to
+92
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This was an actual bug. |
||
options={ [ | ||
{ | ||
label: __( 'Newest to oldest' ), | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -26,8 +26,8 @@ function useUniqueId( idProp?: string ) { | |
return idProp || id; | ||
} | ||
|
||
function UnforwardedSelectControl( | ||
props: WordPressComponentProps< SelectControlProps, 'select', false >, | ||
function UnforwardedSelectControl< T extends string >( | ||
props: WordPressComponentProps< SelectControlProps< T >, 'select', false >, | ||
ref: React.ForwardedRef< HTMLSelectElement > | ||
) { | ||
const { | ||
|
@@ -66,12 +66,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 T | ||
); | ||
props.onChange?.( newValues, { event } ); | ||
return; | ||
} | ||
|
||
props.onChange?.( event.target.value, { event } ); | ||
props.onChange?.( event.target.value as T, { event } ); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These type assertions should be safe, as they will always match the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Technically, this isn't quite true because the select could be edited by someone using dev tools, however, I think this is fine because
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would you be willing to use There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I changed them to |
||
}; | ||
|
||
const classes = clsx( 'components-select-control', className ); | ||
|
@@ -164,6 +166,14 @@ function UnforwardedSelectControl( | |
* }; | ||
* ``` | ||
*/ | ||
export const SelectControl = forwardRef( UnforwardedSelectControl ); | ||
export const SelectControl = forwardRef( UnforwardedSelectControl ) as < | ||
T extends string, | ||
>( | ||
props: WordPressComponentProps< | ||
SelectControlProps< T >, | ||
'select', | ||
false | ||
> & { ref?: React.Ref< HTMLSelectElement > } | ||
) => ReturnType< typeof UnforwardedSelectControl >; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I see what you were going for here, but I think I'd simplify the return type to just There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That makes sense, thanks 🙏 2f195c2 |
||
|
||
export default SelectControl; |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -9,7 +9,7 @@ import type { ChangeEvent, FocusEvent, ReactNode } from 'react'; | |
import type { InputBaseProps } from '../input-control/types'; | ||
import type { BaseControlProps } from '../base-control/types'; | ||
|
||
type SelectControlBaseProps = Pick< | ||
type SelectControlBaseProps< T extends string > = Pick< | ||
InputBaseProps, | ||
| '__next36pxDefaultSize' | ||
| '__next40pxDefaultSize' | ||
|
@@ -24,7 +24,7 @@ type SelectControlBaseProps = Pick< | |
Pick< BaseControlProps, 'help' | '__nextHasNoMarginBottom' > & { | ||
onBlur?: ( event: FocusEvent< HTMLSelectElement > ) => void; | ||
onFocus?: ( event: FocusEvent< HTMLSelectElement > ) => void; | ||
options?: { | ||
options?: readonly { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Adding a |
||
/** | ||
* The label to be shown to the user. | ||
*/ | ||
|
@@ -33,7 +33,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: T; | ||
id?: string; | ||
/** | ||
* Whether or not the option should have the disabled attribute. | ||
|
@@ -61,50 +61,52 @@ 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; | ||
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< T extends string = string > = | ||
SelectControlBaseProps< T > & { | ||
/** | ||
* 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; | ||
value?: NoInfer< T >; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This kind of "don't infer" signaling used to be done with There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can it be demonstrated when the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sure, Without What this means is it won't detect that your value is not one of the provided options, and therefore won't provide an error for it. I've created a simple TS Playground example to illustrate the difference for you. Hope that helps 🙂 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks, this is nice 🙂 I didn't realize that passing something as There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice to see a clever usage of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also found a good article about NoInfer: https://www.totaltypescript.com/noinfer |
||
/** | ||
* 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: T, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we also use the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good one, I added the constraints (and type tests) for those too. |
||
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; | ||
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< T extends string > = | ||
SelectControlBaseProps< T > & { | ||
/** | ||
* 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; | ||
value?: NoInfer< T >[]; | ||
/** | ||
* 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: T[], | ||
extra?: { event?: ChangeEvent< HTMLSelectElement > } | ||
) => void; | ||
}; | ||
|
||
export type SelectControlProps = | ||
| SelectControlSingleSelectionProps | ||
| SelectControlMultipleSelectionProps; | ||
export type SelectControlProps< T extends string = string > = | ||
| SelectControlSingleSelectionProps< T > | ||
| SelectControlMultipleSelectionProps< T >; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The changes we have to make in this file are what make me a bit hesitant about adding this
value
type inference. The stricter types do not break runtime code obviously, but we will be forcing some consumers to deal with this new strictness, which may seem like overkill depending on who you ask.I think we can get away with it, because the strictness will only kick in if the
options
array is defined as immutable (either a literal array in the component call, or with anas const
). For example, there is a potential bug inCustomGradientPicker
but it isn't caught with the new strictness becauseGRADIENT_OPTIONS
is mutable.We don't have a ton of data points in the type checked realm of this repo, but it seems like
TimePicker
is the only "false positive" in the sense that it didn't surface a potential bug. (I just want to be sure that this type tightening is a net positive and not a nuisance 😅)There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree with what you're saying. It would likely be a good thing to document how to specify a loose type (by using
SelectControl<string>()
) so that users who don't want to tighten their other code (in this case, theformat
function) can still have the same level of leniency as they previously had, and then this change empowers users to use tighter types if they like.Though, saying that, I'm not sure where this would be documented. In the
SelectControl
docs as a note perhaps?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is definitely a big aspect to keep in mind. Overall, I think this change is a net positive — I hope that in the future we'll have a way to always apply the strictness, and not only when the
options
array is immutable.We should probably write a dev note for this change, to help consumers of the package deal with this change correctly (especially the ones who need to support multiple versions of Wordpress / Gutenberg).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I added a Dev Note so we at least have something to link to if anybody asks. If we do get some questions, we might need to document it somewhere more permanent. But in general I don't think we should have "TypeScript documentation" aside from our actual type files.