Skip to content
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

DuotonePicker, DuotoneSwatch: Convert to TypeScript #49060

Merged
merged 15 commits into from
Mar 21, 2023
1 change: 1 addition & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
- `navigateRegions` HOC: Convert to TypeScript ([#48632](https://github.com/WordPress/gutenberg/pull/48632)).
- `withSpokenMessages`: HOC: Convert to TypeScript ([#48163](https://github.com/WordPress/gutenberg/pull/48163)).
- `withNotices`: HOC: Convert to TypeScript ([#49088](https://github.com/WordPress/gutenberg/pull/49088)).
- `DuotonePicker`, `DuotoneSwatch`: Convert to TypeScript ([#49060](https://github.com/WordPress/gutenberg/pull/49060)).
mirka marked this conversation as resolved.
Show resolved Hide resolved
- `ToolbarButton`: Convert to TypeScript ([#47750](https://github.com/WordPress/gutenberg/pull/47750)).
- `DimensionControl(Experimental)`: Convert to TypeScript ([#47351](https://github.com/WordPress/gutenberg/pull/47351)).
- `PaletteEdit`: Convert to TypeScript ([#47764](https://github.com/WordPress/gutenberg/pull/47764)).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ import { swatch } from '@wordpress/icons';
/**
* Internal dependencies
*/
import Button from '../button';
import ColorPalette from '../color-palette';
import ColorIndicator from '../color-indicator';
import Icon from '../icon';
import { HStack } from '../h-stack';
import Button from '../../button';
import ColorPalette from '../../color-palette';
import ColorIndicator from '../../color-indicator';
import Icon from '../../icon';
import { HStack } from '../../h-stack';
import type { ColorListPickerProps, ColorOptionProps } from './types';

function ColorOption( {
Expand Down Expand Up @@ -75,7 +75,8 @@ function ColorListPicker( {
disableCustomColors={ disableCustomColors }
enableAlpha={ enableAlpha }
onChange={ ( newColor ) => {
const newColors = value.slice();
const newColors: ( string | undefined )[] =
value.slice();
newColors[ index ] = newColor;
onChange( newColors );
} }
Expand Down
mirka marked this conversation as resolved.
Show resolved Hide resolved
File renamed without changes.
mirka marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export type ColorListPickerProps = {
/**
* An array containing the currently selected colors.
*/
value?: Array< string | undefined >;
value?: Array< string >;
/**
* Controls whether the custom color picker is displayed.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@ import {

const PLACEHOLDER_VALUES = [ '#333', '#CCC' ];

export default function CustomDuotoneBar( { value, onChange } ) {
export default function CustomDuotoneBar( {
value,
onChange,
}: {
value?: string[];
onChange: ( value?: string[] ) => void;
} ) {
const hasGradient = !! value;
const values = hasGradient ? value : PLACEHOLDER_VALUES;
const background = getGradientFromCSSColors( values );
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,48 @@ import { __, sprintf } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import ColorListPicker from '../color-list-picker';
import ColorListPicker from './color-list-picker';
import CircularOptionPicker from '../circular-option-picker';
import { VStack } from '../v-stack';

import CustomDuotoneBar from './custom-duotone-bar';
import { getDefaultColors, getGradientFromCSSColors } from './utils';
import { Spacer } from '../spacer';
import type { DuotonePickerProps } from './types';

/**
* ```jsx
* import { DuotonePicker, DuotoneSwatch } from '@wordpress/components';
* import { useState } from '@wordpress/element';
*
* const DUOTONE_PALETTE = [
* { colors: [ '#8c00b7', '#fcff41' ], name: 'Purple and yellow', slug: 'purple-yellow' },
* { colors: [ '#000097', '#ff4747' ], name: 'Blue and red', slug: 'blue-red' },
* ];
*
* const COLOR_PALETTE = [
* { color: '#ff4747', name: 'Red', slug: 'red' },
* { color: '#fcff41', name: 'Yellow', slug: 'yellow' },
* { color: '#000097', name: 'Blue', slug: 'blue' },
* { color: '#8c00b7', name: 'Purple', slug: 'purple' },
* ];
*
* const Example = () => {
* const [ duotone, setDuotone ] = useState( [ '#000000', '#ffffff' ] );
* return (
* <>
* <DuotonePicker
* duotonePalette={ DUOTONE_PALETTE }
* colorPalette={ COLOR_PALETTE }
* value={ duotone }
* onChange={ setDuotone }
* />
* <DuotoneSwatch values={ duotone } />
* </>
* );
* };
* ```
*/
function DuotonePicker( {
clearable = true,
unsetable = true,
Expand All @@ -29,7 +63,7 @@ function DuotonePicker( {
disableCustomDuotone,
value,
onChange,
} ) {
}: DuotonePickerProps ) {
const [ defaultDark, defaultLight ] = useMemo(
() => getDefaultColors( colorPalette ),
[ colorPalette ]
Expand Down Expand Up @@ -125,6 +159,8 @@ function DuotonePicker( {
newColors.length >= 2
? newColors
: undefined;
// @ts-expect-error TODO: Investigate if this is actually a problem
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's definitely a type mismatch at some point here. ColorListPicker declares that both its value and its onChange deal with Array< string | undefined >, while DuotonePicker doesn't accept undefined in its types.

My gut instinct would be to:

  • understand what actually happened in DuotonePicker and ColorListPicker when the value was undefined
  • maintain the same behaviour, while adding more explicit checks (ie. small runtime changes) if necessary (e.g adding a typeguard in ColorListPicker against undefined values?)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright, @chad1008 and I took a deeper look at this, and came to the conclusion that in order to fix the TS mismatch properly we'd need to rewrite the component in such a way that enforces value and onChange to deal with a 2 item array (which kind of relates to this other convo ).

A quicker way to address the TS error for this PR, while introducing the least amount of runtime changes possible, could be:

  1. (Optional) Slightly tweak ColorListPicker types
Like this
diff --git a/packages/components/src/duotone-picker/color-list-picker/index.tsx b/packages/components/src/duotone-picker/color-list-picker/index.tsx
index 8a65382482..cff79027d0 100644
--- a/packages/components/src/duotone-picker/color-list-picker/index.tsx
+++ b/packages/components/src/duotone-picker/color-list-picker/index.tsx
@@ -75,9 +75,11 @@ function ColorListPicker( {
 					disableCustomColors={ disableCustomColors }
 					enableAlpha={ enableAlpha }
 					onChange={ ( newColor ) => {
-						const newColors = value.slice();
-						newColors[ index ] = newColor;
-						onChange( newColors );
+						onChange( [
+							...value.slice( 0, index ),
+							newColor,
+							...value.slice( index + 1 ),
+						] );
 					} }
 				/>
 			) ) }
diff --git a/packages/components/src/duotone-picker/color-list-picker/types.ts b/packages/components/src/duotone-picker/color-list-picker/types.ts
index becfc405b2..73c1900235 100644
--- a/packages/components/src/duotone-picker/color-list-picker/types.ts
+++ b/packages/components/src/duotone-picker/color-list-picker/types.ts
@@ -21,7 +21,7 @@ export type ColorListPickerProps = {
 	/**
 	 * An array containing the currently selected colors.
 	 */
-	value?: Array< string | undefined >;
+	value?: Array< string >;
 	/**
 	 * Controls whether the custom color picker is displayed.
 	 */
  1. Cast the newColors array as string[]. This is not a 100% correct cast, since in theory newColors could still have undefined items at index 2 and up, but we kind of know that in practice that's not supposed to happen.
Like this
diff --git a/packages/components/src/duotone-picker/duotone-picker.tsx b/packages/components/src/duotone-picker/duotone-picker.tsx
index 7d854242e5..4f909b01fe 100644
--- a/packages/components/src/duotone-picker/duotone-picker.tsx
+++ b/packages/components/src/duotone-picker/duotone-picker.tsx
@@ -157,9 +157,8 @@ function DuotonePicker( {
 								}
 								const newValue =
 									newColors.length >= 2
-										? newColors
+										? ( newColors as string[] )
 										: undefined;
-								// @ts-expect-error TODO: Investigate if this is actually a problem
 								onChange( newValue );
 							} }
 						/>

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for looking into it, Marco + Chad!

I'm not quite convinced that the string[] cast is any better than a ts-expect-error — if anything it can imply a false sense of safety. Do you prefer this cast rather than leaving the error for proper fixing later? If not, I can update the TODO comment so it links to this PR thread 😄

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You make a good point — we can leave the @ts-expect-error instead of the typecast to avoid the false sense of safety (plus, we will get a TS error if we ever fix those types!).

What do you think about the tweaks suggested in the first point above?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about this: 6ee7027

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Works for me!

Last thing I'd suggest is to improve the comment associated to the @ts-expect-error, explaining that it caused by the fact that typescript doesn't know that both newColors and value are in fact supposed to be a 2 item tuple, instead of a string[]

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2c31a5c

// See also https://github.com/WordPress/gutenberg/pull/49060#discussion_r1136951035
onChange( newValue );
} }
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ import { swatch } from '@wordpress/icons';
import ColorIndicator from '../color-indicator';
import Icon from '../icon';
import { getGradientFromCSSColors } from './utils';
import type { DuotoneSwatchProps } from './types';

function DuotoneSwatch( { values } ) {
function DuotoneSwatch( { values }: DuotoneSwatchProps ) {
return values ? (
<ColorIndicator
colorValue={ getGradientFromCSSColors( values, '135deg' ) }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
/**
* External dependencies
*/
import type { ComponentMeta, ComponentStory } from '@storybook/react';

/**
* WordPress dependencies
*/
Expand All @@ -6,23 +11,22 @@ import { useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import { DuotonePicker } from '../';
import { DuotonePicker } from '..';
import type { DuotonePickerProps } from '../types';

export default {
const meta: ComponentMeta< typeof DuotonePicker > = {
title: 'Components/DuotonePicker',
component: DuotonePicker,
argTypes: {
clearable: { control: { type: 'boolean' } },
disableCustomColors: { control: { type: 'boolean' } },
disableCustomDuotone: { control: { type: 'boolean' } },
onChange: { action: 'onChange' },
unsetable: { control: { type: 'boolean' } },
value: { control: { type: null } },
},
parameters: {
controls: { expanded: true },
docs: { source: { state: 'open' } },
},
};
export default meta;

const DUOTONE_PALETTE = [
{
Expand All @@ -44,8 +48,11 @@ const COLOR_PALETTE = [
{ color: '#8c00b7', name: 'Purple', slug: 'purple' },
];

const Template = ( { onChange, ...args } ) => {
const [ value, setValue ] = useState();
const Template: ComponentStory< typeof DuotonePicker > = ( {
onChange,
...args
} ) => {
const [ value, setValue ] = useState< DuotonePickerProps[ 'value' ] >();

return (
<DuotonePicker
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
/**
* External dependencies
*/
import type { ComponentMeta, ComponentStory } from '@storybook/react';

/**
* Internal dependencies
*/
import { DuotoneSwatch } from '../';
import { DuotoneSwatch } from '..';

export default {
const meta: ComponentMeta< typeof DuotoneSwatch > = {
title: 'Components/DuotoneSwatch',
component: DuotoneSwatch,
parameters: {
controls: { expanded: true },
docs: { source: { state: 'open' } },
},
};
export default meta;

const Template = ( args ) => {
const Template: ComponentStory< typeof DuotoneSwatch > = ( args ) => {
return <DuotoneSwatch { ...args } />;
};

Expand Down
61 changes: 61 additions & 0 deletions packages/components/src/duotone-picker/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
export type DuotonePickerProps = {
/**
* Whether there should be a button to clear the duotone value.
*
* @default true
*/
clearable?: boolean;
/**
* Whether there should be an `unset` option.
*
* @default true
*/
unsetable?: boolean;
/**
* Array of color presets of the form `{ color: '#000000', name: 'Black', slug: 'black' }`.
*/
colorPalette: Color[];
/**
* Array of duotone presets of the form `{ colors: [ '#000000', '#ffffff' ], name: 'Grayscale', slug: 'grayscale' }`.
*/
duotonePalette: DuotoneColor[];
/**
* Whether custom colors should be disabled.
*
* @default false
*/
disableCustomColors?: boolean;
/**
* Whether custom duotone values should be disabled.
*
* @default false
*/
disableCustomDuotone?: boolean;
/**
* An array of colors for the duotone effect.
*/
value?: string[] | 'unset';
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered whether it was worth doing a tuple ([ string, string ]) here instead of string[], but decided against it for two reasons:

  • The upstream CustomGradientPicker accepts string[], so we'll need to do some added type massaging there.
  • I noticed when typing the stories that consumers may be forced to do [ '#foo', '#bar' ] as const to make the type checks pass, which is non-obvious.

Does that sound reasonable?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A tuple would be definitely better in representing the actual type that the component is expecting — without it, TS wouldn't be able to detect a malformed value (ie. [], [ '#fff' ] etc).

But the points that you make are valid, and therefore I'd be ok with typing it as string[], at least initially. We can always narrow the type (or put more runtime checks in place) later as we see fit

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As you pointed out recently, the new const type improvements may come handy for this scenario!

/**
* Callback which is called when the duotone colors change.
*/
onChange: ( value: DuotonePickerProps[ 'value' ] | undefined ) => void;
};

type Color = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Not required for this PR, so feel free to ignore)

As we work on color-related components, it would be great to take a wider look at these components' types, and see if we can better highlight dependencies and/or find inconsisntencies.

For example, the Color type here (and the type of the colors prop for the ColorListPicker component) could be derived from the ColorObject type from ColorPalette

color: string;
name: string;
slug: string;
};

type DuotoneColor = {
colors: string[];
name: string;
slug: string;
};

export type DuotoneSwatchProps = {
/**
* An array of colors to show or `null` to show the placeholder swatch icon.
*/
values?: string[] | null;
};
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
import { colord, extend } from 'colord';
import namesPlugin from 'colord/plugins/names';

/**
* Internal dependencies
*/
import type { DuotonePickerProps } from './types';

extend( [ namesPlugin ] );

/**
Expand All @@ -18,11 +23,13 @@ extend( [ namesPlugin ] );
/**
* Calculate the brightest and darkest values from a color palette.
*
* @param {Object[]} palette Color palette for the theme.
* @param palette Color palette for the theme.
*
* @return {string[]} Tuple of the darkest color and brightest color.
* @return Tuple of the darkest color and brightest color.
*/
export function getDefaultColors( palette ) {
export function getDefaultColors(
palette: DuotonePickerProps[ 'colorPalette' ]
) {
// A default dark and light color are required.
if ( ! palette || palette.length < 2 ) return [ '#000', '#fff' ];

Expand All @@ -38,20 +45,26 @@ export function getDefaultColors( palette ) {
current.brightness >= max.brightness ? current : max,
];
},
[ { brightness: 1 }, { brightness: 0 } ]
[
{ brightness: 1, color: '' },
{ brightness: 0, color: '' },
]
)
.map( ( { color } ) => color );
}
Comment on lines +48 to 54
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An interesting example of how some clever functional code may not play nice with TS 😆 The initial values ([ { brightness: 1 }, { brightness: 0 } ]) will never be remaining at the point of the final .map(), but TS cannot know that.

I think this is probably the least annoying/invasive way around it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is probably the least annoying/invasive way around it.

Agreed!

Although annoying at times, I'm glad that TS is able to pick up these edge cases. The fact that the author of these lines of code was clever in writing the original implementation doesn't necessarily mean that a malformed color palette (e.g with out of scale brightness values) or another developer making amends to the algorithm could introduce a bug later in time. TS checks are great in avoiding such events

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Though for this particular function I would rely on some unit tests more than TS 🫣

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's true! I guess in my mind I implicitly considered TS static linting as a the first test that runs against the code.


/**
* Generate a duotone gradient from a list of colors.
*
* @param {string[]} colors CSS color strings.
* @param {string} angle CSS gradient angle.
* @param colors CSS color strings.
* @param angle CSS gradient angle.
*
* @return {string} CSS gradient string for the duotone swatch.
* @return CSS gradient string for the duotone swatch.
*/
export function getGradientFromCSSColors( colors = [], angle = '90deg' ) {
export function getGradientFromCSSColors(
colors: string[] = [],
angle = '90deg'
) {
const l = 100 / colors.length;

const stops = colors
Expand All @@ -64,11 +77,11 @@ export function getGradientFromCSSColors( colors = [], angle = '90deg' ) {
/**
* Convert a color array to an array of color stops.
*
* @param {string[]} colors CSS colors array
* @param colors CSS colors array
*
* @return {Object[]} Color stop information.
* @return Color stop information.
*/
export function getColorStopsFromColors( colors ) {
export function getColorStopsFromColors( colors: string[] ) {
return colors.map( ( color, i ) => ( {
position: ( i * 100 ) / ( colors.length - 1 ),
color,
Expand All @@ -78,10 +91,12 @@ export function getColorStopsFromColors( colors ) {
/**
* Convert a color stop array to an array colors.
*
* @param {Object[]} colorStops Color stop information.
* @param colorStops Color stop information.
*
* @return {string[]} CSS colors array.
* @return CSS colors array.
*/
export function getColorsFromColorStops( colorStops = [] ) {
export function getColorsFromColorStops(
colorStops: { position: number; color: string }[] = []
mirka marked this conversation as resolved.
Show resolved Hide resolved
) {
return colorStops.map( ( { color } ) => color );
}
2 changes: 1 addition & 1 deletion packages/components/src/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
@import "./palette-edit/style.scss";
@import "./color-indicator/style.scss";
@import "./combobox-control/style.scss";
@import "./color-list-picker/style.scss";
@import "./color-palette/style.scss";
@import "./custom-gradient-picker/style.scss";
@import "./custom-select-control/style.scss";
Expand All @@ -21,6 +20,7 @@
@import "./dropdown/style.scss";
@import "./dropdown-menu/style.scss";
@import "./duotone-picker/style.scss";
@import "./duotone-picker/color-list-picker/style.scss";
@import "./form-toggle/style.scss";
@import "./form-token-field/style.scss";
@import "./guide/style.scss";
Expand Down
3 changes: 1 addition & 2 deletions packages/components/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@
"src/**/react-native-*",
"src/**/stories/**/*.js", // only exclude js files, tsx files should be checked
"src/**/test/**/*.js", // only exclude js files, ts{x} files should be checked
"src/index.js",
"src/duotone-picker"
"src/index.js"
]
}