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
4 changes: 4 additions & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
- `CustomGradientPicker`: improve initial state UI ([#49146](https://github.com/WordPress/gutenberg/pull/49146)).
- `AnglePickerControl`: Style to better fit in narrow contexts and improve RTL layout ([#49046](https://github.com/WordPress/gutenberg/pull/49046)).

### Internal

- `DuotonePicker`, `DuotoneSwatch`: Convert to TypeScript ([#49060](https://github.com/WordPress/gutenberg/pull/49060)).

## 23.6.0 (2023-03-15)

### Enhancements
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
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { CSSProperties } from 'react';

export type ColorListPickerProps = {
/**
* A list of predifened colors. Each color is an object with a `name` and a
* A list of predefined colors. Each color is an object with a `name` and a
* `color` value.
* The `name` is a string used to identify the color in the UI.
* The `color` is a valid CSS color string.
Expand All @@ -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,9 @@ function DuotonePicker( {
newColors.length >= 2
? newColors
: undefined;
// @ts-expect-error TODO: The color arrays for a DuotonePicker should be a tuple of two colors,
// but it's currently typed as a string[].
// 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 );
}
Loading