diff --git a/docs/how-to-guides/themes/theme-json.md b/docs/how-to-guides/themes/theme-json.md
index d1f0df50ed148..f2465e4df1082 100644
--- a/docs/how-to-guides/themes/theme-json.md
+++ b/docs/how-to-guides/themes/theme-json.md
@@ -183,6 +183,7 @@ The settings section has the following structure and default values:
"color": {
"custom": true, /* false to opt-out, as in add_theme_support('disable-custom-colors') */
"customGradient": true, /* false to opt-out, as in add_theme_support('disable-custom-gradients') */
+ "duotone": [ ... ], /* duotone presets, a list of { "colors": [ "#000", "#FFF" ], "slug": "black-and-white", "name": "Black and White" } */
"gradients": [ ... ], /* gradient presets, as in add_theme_support('editor-gradient-presets', ... ) */
"link": false, /* true to opt-in, as in add_theme_support('experimental-link-color') */
"palette": [ ... ], /* color presets, as in add_theme_support('editor-color-palette', ... ) */
diff --git a/docs/reference-guides/block-api/block-supports.md b/docs/reference-guides/block-api/block-supports.md
index 565b1f0bbbd8e..ff3f50cad8d85 100644
--- a/docs/reference-guides/block-api/block-supports.md
+++ b/docs/reference-guides/block-api/block-supports.md
@@ -86,12 +86,11 @@ supports: {
- Default value: null
- Subproperties:
- `background`: type `boolean`, default value `true`
+ - `duotone`: type `string`, default value undefined
- `gradients`: type `boolean`, default value `false`
- `text`: type `boolean`, default value `true`
-This value signals that a block supports some of the CSS style properties related to color. When it does, the block editor will show UI controls for the user to set their values.
-
-The controls for background and text will source their colors from the `editor-color-palette` [theme support](https://developer.wordpress.org/block-editor/developers/themes/theme-support/#block-color-palettes), while the gradient's from `editor-gradient-presets` [theme support](https://developer.wordpress.org/block-editor/developers/themes/theme-support/#block-gradient-presets).
+This value signals that a block supports some of the properties related to color. When it does, the block editor will show UI controls for the user to set their values.
Note that the `text` and `background` keys have a default value of `true`, so if the `color` property is present they'll also be considered enabled:
@@ -115,58 +114,227 @@ supports: {
}
```
-When the block has support for a specific color property, the attributes definition is extended to include some attributes.
+### color.background
+
+This property adds UI controls which allow the user to apply a solid background color to a block.
-- `style`: attribute of `object` type with no default assigned. This is added when any of support color properties are declared. It stores the custom values set by the user. The block can apply a default style by specifying its own `style` attribute with a default e.g.:
+When color support is declared, this property is enabled by default (along with text), so simply setting color will enable background color.
```js
-attributes: {
- style: {
- type: 'object',
- default: {
- color: {
- background: 'value',
- gradient: 'value',
- text: 'value'
- }
- }
+supports: {
+ color: true // Enable both background and text
+}
+```
+
+To disable background support while keeping other color supports enabled, set to `false`.
+
+```js
+supports: {
+ color: {
+ // Disable background support. Text color support is still enabled.
+ background: false
}
}
```
-- When `background` support is declared: it'll be added a new `backgroundColor` attribute of type `string` with no default assigned. It stores the preset values set by the user. The block can apply a default background color by specifying its own attribute with a default e.g.:
+When the block declares support for `color.background`, the attributes definition is extended to include two new attributes: `backgroundColor` and `style`:
+
+- `backgroundColor`: attribute of `string` type with no default assigned.
+
+ When a user chooses from the list of preset background colors, the preset slug is stored in the `backgroundColor` attribute.
+
+ Background color presets are sourced from the `editor-color-palette` [theme support](https://developer.wordpress.org/block-editor/developers/themes/theme-support/#block-color-palettes).
+
+ The block can apply a default preset background color by specifying its own attribute with a default e.g.:
+
+ ```js
+ attributes: {
+ backgroundColor: {
+ type: 'string',
+ default: 'some-preset-background-slug',
+ }
+ }
+ ```
+
+- `style`: attribute of `object` type with no default assigned.
+
+ When a custom background color is selected (i.e. using the custom color picker), the custom color value is stored in the `style.color.background` attribute.
+
+ The block can apply a default custom background color by specifying its own attribute with a default e.g.:
+
+ ```js
+ attributes: {
+ style: {
+ type: 'object',
+ default: {
+ color: {
+ background: '#aabbcc',
+ }
+ }
+ }
+ }
+ ```
+
+### color.__experimentalDuotone
+
+This property adds UI controls which allow to apply a duotone filter to a block or part of a block.
+
+The parent selector is automatically added much like nesting in Sass/SCSS (however, the `&` selector is not supported).
```js
-attributes: {
- backgroundColor: {
- type: 'string',
- default: 'some-value',
+supports: {
+ color: {
+ // Apply the filter to the same selector in both edit and save.
+ __experimentalDuotone: '> .duotone-img, > .duotone-video',
+
+ // Default values must be disabled if you don't want to use them with duotone.
+ background: false,
+ text: false
}
}
```
-- When `gradients` support is declared: it'll be added a new `gradient` attribute of type `string` with no default assigned. It stores the preset values set by the user. The block can apply a default text color by specifying its own attribute with a default e.g.:
+Duotone presets are sourced from `color.duotone` in [theme.json](https://developer.wordpress.org/block-editor/developers/themes/theme-json/).
+
+When the block declares support for `color.duotone`, the attributes definition is extended to include the attribute `style`:
+
+- `style`: attribute of `object` type with no default assigned.
+
+ The block can apply a default duotone color by specifying its own attribute with a default e.g.:
+
+ ```js
+ attributes: {
+ style: {
+ type: 'object',
+ default: {
+ color: {
+ duotone: [
+ '#FFF',
+ '#000
+ ]
+ }
+ }
+ }
+ }
+ ```
+
+### color.gradients
+
+This property adds UI controls which allow the user to apply a gradient background to a block.
```js
-attributes: {
- gradient: {
- type: 'string',
- default: 'some-value',
+supports: {
+ color: {
+ gradient: true,
+
+ // Default values must be disabled if you don't want to use them with gradient.
+ background: false,
+ text: false
}
}
```
-- When `text` support is declared: it'll be added a new `textColor` attribute of type `string` with no default assigned. It stores the preset values set by the user. The block can apply a default text color by specifying its own attribute with a default e.g.:
+Gradient presets are sourced from `editor-gradient-presets` [theme support](https://developer.wordpress.org/block-editor/developers/themes/theme-support/#block-gradient-presets).
+
+
+When the block declares support for `color.gradient`, the attributes definition is extended to include two new attributes: `gradient` and `style`:
+
+- `gradient`: attribute of `string` type with no default assigned.
+
+ When a user chooses from the list of preset gradients, the preset slug is stored in the `gradient` attribute.
+
+ The block can apply a default preset gradient by specifying its own attribute with a default e.g.:
+
+ ```js
+ attributes: {
+ gradient: {
+ type: 'string',
+ default: 'some-preset-gradient-slug',
+ }
+ }
+ ```
+
+- `style`: attribute of `object` type with no default assigned.
+
+ When a custom gradient is selected (i.e. using the custom gradient picker), the custom gradient value is stored in the `style.color.gradient` attribute.
+
+ The block can apply a default custom gradient by specifying its own attribute with a default e.g.:
+
+ ```js
+ attributes: {
+ style: {
+ type: 'object',
+ default: {
+ color: {
+ background: 'linear-gradient(135deg,rgb(170,187,204) 0%,rgb(17,34,51) 100%)',
+ }
+ }
+ }
+ }
+ ```
+
+### color.text
+
+This property adds block controls which allow the user to set text color in a block.
+
+When color support is declared, this property is enabled by default (along with background), so simply setting color will enable text color.
```js
-attributes: {
- textColor: {
- type: 'string',
- default: 'some-value',
+supports: {
+ color: true // Enable both text and background
+}
+```
+
+To disable text color support while keeping other color supports enabled, set to `false`.
+
+```js
+supports: {
+ color: {
+ // Disable text color support. Background support is still enabled.
+ text: false
}
}
```
+Text color presets are sourced from the `editor-color-palette` [theme support](https://developer.wordpress.org/block-editor/developers/themes/theme-support/#block-color-palettes).
+
+
+When the block declares support for `color.text`, the attributes definition is extended to include two new attributes: `textColor` and `style`:
+
+- `textColor`: attribute of `string` type with no default assigned.
+
+ When a user chooses from the list of preset text colors, the preset slug is stored in the `textColor` attribute.
+
+ The block can apply a default preset text color by specifying its own attribute with a default e.g.:
+
+ ```js
+ attributes: {
+ textColor: {
+ type: 'string',
+ default: 'some-preset-text-color-slug',
+ }
+ }
+ ```
+
+- `style`: attribute of `object` type with no default assigned.
+
+ When a custom text color is selected (i.e. using the custom color picker), the custom color value is stored in the `style.color.text` attribute.
+
+ The block can apply a default custom text color by specifying its own attribute with a default e.g.:
+
+ ```js
+ attributes: {
+ style: {
+ type: 'object',
+ default: {
+ color: {
+ text: '#aabbcc',
+ }
+ }
+ }
+ }
+ ```
+
## customClassName
- Type: `boolean`
diff --git a/lib/block-supports/duotone.php b/lib/block-supports/duotone.php
new file mode 100644
index 0000000000000..f3b4743832f0a
--- /dev/null
+++ b/lib/block-supports/duotone.php
@@ -0,0 +1,364 @@
+ gutenberg_tinycolor_bound01( $rgb_color['r'], 255 ) * 255,
+ 'g' => gutenberg_tinycolor_bound01( $rgb_color['g'], 255 ) * 255,
+ 'b' => gutenberg_tinycolor_bound01( $rgb_color['b'], 255 ) * 255,
+ );
+}
+
+/**
+ * Helper function for hsl to rgb conversion.
+ *
+ * @see https://github.com/bgrins/TinyColor
+ *
+ * @param float $p first component.
+ * @param float $q second component.
+ * @param float $t third component.
+ * @return float R, G, or B component.
+ */
+function gutenberg_tinycolor_hue_to_rgb( $p, $q, $t ) {
+ if ( $t < 0 ) {
+ $t += 1;
+ }
+ if ( $t > 1 ) {
+ $t -= 1;
+ }
+ if ( $t < 1 / 6 ) {
+ return $p + ( $q - $p ) * 6 * $t;
+ }
+ if ( $t < 1 / 2 ) {
+ return $q;
+ }
+ if ( $t < 2 / 3 ) {
+ return $p + ( $q - $p ) * ( 2 / 3 - $t ) * 6;
+ }
+ return $p;
+}
+
+/**
+ * Convert an HSL object to an RGB object with converted and rounded values.
+ *
+ * @see https://github.com/bgrins/TinyColor
+ *
+ * @param array $hsl_color HSL object.
+ * @return array Rounded and converted RGB object.
+ */
+function gutenberg_tinycolor_hsl_to_rgb( $hsl_color ) {
+ $h = gutenberg_tinycolor_bound01( $hsl_color['h'], 360 );
+ $s = gutenberg_tinycolor_bound01( $hsl_color['s'], 100 );
+ $l = gutenberg_tinycolor_bound01( $hsl_color['l'], 100 );
+
+ if ( 0 === $s ) {
+ // Achromatic.
+ $r = $l;
+ $g = $l;
+ $b = $l;
+ } else {
+ $q = $l < 0.5 ? $l * ( 1 + $s ) : $l + $s - $l * $s;
+ $p = 2 * $l - $q;
+ $r = gutenberg_tinycolor_hue_to_rgb( $p, $q, $h + 1 / 3 );
+ $g = gutenberg_tinycolor_hue_to_rgb( $p, $q, $h );
+ $b = gutenberg_tinycolor_hue_to_rgb( $p, $q, $h - 1 / 3 );
+ }
+
+ return array(
+ 'r' => $r * 255,
+ 'g' => $g * 255,
+ 'b' => $b * 255,
+ );
+}
+
+/**
+ * Parses hex, hsl, and rgb CSS strings using the same regex as tinycolor v1.4.2
+ * used in the JavaScript. Only colors output from react-color are implemented
+ * and the alpha value is ignored as it is not used in duotone.
+ *
+ * @see https://github.com/bgrins/TinyColor
+ * @see https://github.com/casesandberg/react-color/
+ *
+ * @param string $color_str CSS color string.
+ * @return array RGB object.
+ */
+function gutenberg_tinycolor_string_to_rgb( $color_str ) {
+ $color_str = strtolower( trim( $color_str ) );
+
+ $css_integer = '[-\\+]?\\d+%?';
+ $css_number = '[-\\+]?\\d*\\.\\d+%?';
+
+ $css_unit = '(?:' . $css_number . ')|(?:' . $css_integer . ')';
+
+ $permissive_match3 = '[\\s|\\(]+(' . $css_unit . ')[,|\\s]+(' . $css_unit . ')[,|\\s]+(' . $css_unit . ')\\s*\\)?';
+ $permissive_match4 = '[\\s|\\(]+(' . $css_unit . ')[,|\\s]+(' . $css_unit . ')[,|\\s]+(' . $css_unit . ')[,|\\s]+(' . $css_unit . ')\\s*\\)?';
+
+ $rgb_regexp = '/^rgb' . $permissive_match3 . '$/';
+ if ( preg_match( $rgb_regexp, $color_str, $match ) ) {
+ return gutenberg_tinycolor_rgb_to_rgb(
+ array(
+ 'r' => $match[1],
+ 'g' => $match[2],
+ 'b' => $match[3],
+ )
+ );
+ }
+
+ $rgba_regexp = '/^rgba' . $permissive_match4 . '$/';
+ if ( preg_match( $rgba_regexp, $color_str, $match ) ) {
+ return gutenberg_tinycolor_rgb_to_rgb(
+ array(
+ 'r' => $match[1],
+ 'g' => $match[2],
+ 'b' => $match[3],
+ )
+ );
+ }
+
+ $hsl_regexp = '/^hsl' . $permissive_match3 . '$/';
+ if ( preg_match( $hsl_regexp, $color_str, $match ) ) {
+ return gutenberg_tinycolor_hsl_to_rgb(
+ array(
+ 'h' => $match[1],
+ 's' => $match[2],
+ 'l' => $match[3],
+ )
+ );
+ }
+
+ $hsla_regexp = '/^hsla' . $permissive_match4 . '$/';
+ if ( preg_match( $hsla_regexp, $color_str, $match ) ) {
+ return gutenberg_tinycolor_hsl_to_rgb(
+ array(
+ 'h' => $match[1],
+ 's' => $match[2],
+ 'l' => $match[3],
+ )
+ );
+ }
+
+ $hex8_regexp = '/^#?([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/';
+ if ( preg_match( $hex8_regexp, $color_str, $match ) ) {
+ return gutenberg_tinycolor_rgb_to_rgb(
+ array(
+ 'r' => base_convert( $match[1], 16, 10 ),
+ 'g' => base_convert( $match[2], 16, 10 ),
+ 'b' => base_convert( $match[3], 16, 10 ),
+ )
+ );
+ }
+
+ $hex6_regexp = '/^#?([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/';
+ if ( preg_match( $hex6_regexp, $color_str, $match ) ) {
+ return gutenberg_tinycolor_rgb_to_rgb(
+ array(
+ 'r' => base_convert( $match[1], 16, 10 ),
+ 'g' => base_convert( $match[2], 16, 10 ),
+ 'b' => base_convert( $match[3], 16, 10 ),
+ )
+ );
+ }
+
+ $hex4_regexp = '/^#?([0-9a-fA-F]{1})([0-9a-fA-F]{1})([0-9a-fA-F]{1})([0-9a-fA-F]{1})$/';
+ if ( preg_match( $hex4_regexp, $color_str, $match ) ) {
+ return gutenberg_tinycolor_rgb_to_rgb(
+ array(
+ 'r' => base_convert( $match[1] . $match[1], 16, 10 ),
+ 'g' => base_convert( $match[2] . $match[2], 16, 10 ),
+ 'b' => base_convert( $match[3] . $match[3], 16, 10 ),
+ )
+ );
+ }
+
+ $hex3_regexp = '/^#?([0-9a-fA-F]{1})([0-9a-fA-F]{1})([0-9a-fA-F]{1})$/';
+ if ( preg_match( $hex3_regexp, $color_str, $match ) ) {
+ return gutenberg_tinycolor_rgb_to_rgb(
+ array(
+ 'r' => base_convert( $match[1] . $match[1], 16, 10 ),
+ 'g' => base_convert( $match[2] . $match[2], 16, 10 ),
+ 'b' => base_convert( $match[3] . $match[3], 16, 10 ),
+ )
+ );
+ }
+}
+
+
+/**
+ * Registers the style and colors block attributes for block types that support it.
+ *
+ * @param WP_Block_Type $block_type Block Type.
+ */
+function gutenberg_register_duotone_support( $block_type ) {
+ $has_duotone_support = false;
+ if ( property_exists( $block_type, 'supports' ) ) {
+ $has_duotone_support = _wp_array_get( $block_type->supports, array( 'color', '__experimentalDuotone' ), false );
+ }
+
+ if ( $has_duotone_support ) {
+ if ( ! $block_type->attributes ) {
+ $block_type->attributes = array();
+ }
+
+ if ( ! array_key_exists( 'style', $block_type->attributes ) ) {
+ $block_type->attributes['style'] = array(
+ 'type' => 'object',
+ );
+ }
+ }
+}
+
+/**
+ * Render out the duotone stylesheet and SVG.
+ *
+ * @param string $block_content Rendered block content.
+ * @param array $block Block object.
+ * @return string Filtered block content.
+ */
+function gutenberg_render_duotone_support( $block_content, $block ) {
+ $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block['blockName'] );
+
+ $duotone_support = false;
+ if ( $block_type && property_exists( $block_type, 'supports' ) ) {
+ $duotone_support = _wp_array_get( $block_type->supports, array( 'color', '__experimentalDuotone' ), false );
+ }
+
+ $has_duotone_attribute = isset( $block['attrs']['style']['color']['duotone'] );
+
+ if (
+ ! $duotone_support ||
+ ! $has_duotone_attribute
+ ) {
+ return $block_content;
+ }
+
+ $duotone_colors = $block['attrs']['style']['color']['duotone'];
+
+ $duotone_values = array(
+ 'r' => array(),
+ 'g' => array(),
+ 'b' => array(),
+ );
+ foreach ( $duotone_colors as $color_str ) {
+ $color = gutenberg_tinycolor_string_to_rgb( $color_str );
+
+ $duotone_values['r'][] = $color['r'] / 255;
+ $duotone_values['g'][] = $color['g'] / 255;
+ $duotone_values['b'][] = $color['b'] / 255;
+ }
+
+ $duotone_id = 'wp-duotone-filter-' . uniqid();
+
+ $selectors = explode( ',', $duotone_support );
+ $selectors_scoped = array_map(
+ function ( $selector ) use ( $duotone_id ) {
+ return '.' . $duotone_id . ' ' . trim( $selector );
+ },
+ $selectors
+ );
+ $selectors_group = implode( ', ', $selectors_scoped );
+
+ ob_start();
+
+ ?>
+
+
+
+
+
+ register(
+ 'duotone',
+ array(
+ 'register_attribute' => 'gutenberg_register_duotone_support',
+ )
+);
+add_filter( 'render_block', 'gutenberg_render_duotone_support', 10, 2 );
diff --git a/lib/class-wp-theme-json.php b/lib/class-wp-theme-json.php
index e81472dbd23b1..76e26cd1d4fff 100644
--- a/lib/class-wp-theme-json.php
+++ b/lib/class-wp-theme-json.php
@@ -108,6 +108,7 @@ class WP_Theme_JSON {
'gradients' => null,
'link' => null,
'palette' => null,
+ 'duotone' => null,
),
'spacing' => array(
'customPadding' => null,
diff --git a/lib/experimental-default-theme.json b/lib/experimental-default-theme.json
index 5eebd221e8a2c..447a768dcd5bd 100644
--- a/lib/experimental-default-theme.json
+++ b/lib/experimental-default-theme.json
@@ -126,6 +126,48 @@
"slug": "midnight"
}
],
+ "duotone": [
+ {
+ "name": "Dark grayscale" ,
+ "colors": [ "#000000", "#7f7f7f" ],
+ "slug": "dark-grayscale"
+ },
+ {
+ "name": "Grayscale" ,
+ "colors": [ "#000000", "#ffffff" ],
+ "slug": "grayscale"
+ },
+ {
+ "name": "Purple and yellow" ,
+ "colors": [ "#8c00b7", "#fcff41" ],
+ "slug": "purple-yellow"
+ },
+ {
+ "name": "Blue and red" ,
+ "colors": [ "#000097", "#ff4747" ],
+ "slug": "blue-red"
+ },
+ {
+ "name": "Midnight" ,
+ "colors": [ "#000000", "#00a5ff" ],
+ "slug": "midnight"
+ },
+ {
+ "name": "Magenta and yellow" ,
+ "colors": [ "#c7005a", "#fff278" ],
+ "slug": "magenta-yellow"
+ },
+ {
+ "name": "Purple and green" ,
+ "colors": [ "#a60072", "#67ff66" ],
+ "slug": "purple-green"
+ },
+ {
+ "name": "Blue and orange" ,
+ "colors": [ "#1900d8", "#ffa96b" ],
+ "slug": "blue-orange"
+ }
+ ],
"custom": true,
"link": false,
"customGradient": true
diff --git a/lib/experimental-i18n-theme.json b/lib/experimental-i18n-theme.json
index 94889f733a331..a9dc4c429ea47 100644
--- a/lib/experimental-i18n-theme.json
+++ b/lib/experimental-i18n-theme.json
@@ -43,6 +43,11 @@
{
"name": "Gradient name"
}
+ ],
+ "duotone": [
+ {
+ "name": "Duotone name"
+ }
]
}
}
diff --git a/lib/load.php b/lib/load.php
index 2deda54b6646b..dcf9539a1e526 100644
--- a/lib/load.php
+++ b/lib/load.php
@@ -126,3 +126,4 @@ function gutenberg_is_experiment_enabled( $name ) {
require __DIR__ . '/block-supports/border.php';
require __DIR__ . '/block-supports/layout.php';
require __DIR__ . '/block-supports/padding.php';
+require __DIR__ . '/block-supports/duotone.php';
diff --git a/packages/block-editor/src/components/duotone-control/duotone-picker-popover.js b/packages/block-editor/src/components/duotone-control/duotone-picker-popover.js
new file mode 100644
index 0000000000000..7926cd5f977a6
--- /dev/null
+++ b/packages/block-editor/src/components/duotone-control/duotone-picker-popover.js
@@ -0,0 +1,32 @@
+/**
+ * WordPress dependencies
+ */
+import { Popover, MenuGroup, DuotonePicker } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+
+function DuotonePickerPopover( {
+ value,
+ onChange,
+ onToggle,
+ duotonePalette,
+ colorPalette,
+} ) {
+ return (
+
+
+
+
+
+ );
+}
+
+export default DuotonePickerPopover;
diff --git a/packages/block-editor/src/components/duotone-control/index.js b/packages/block-editor/src/components/duotone-control/index.js
new file mode 100644
index 0000000000000..c7f597c561237
--- /dev/null
+++ b/packages/block-editor/src/components/duotone-control/index.js
@@ -0,0 +1,50 @@
+/**
+ * WordPress dependencies
+ */
+import { ToolbarButton, DuotoneSwatch } from '@wordpress/components';
+import { useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { DOWN } from '@wordpress/keycodes';
+
+/**
+ * Internal dependencies
+ */
+import DuotonePickerPopover from './duotone-picker-popover';
+
+function DuotoneControl( { colorPalette, duotonePalette, value, onChange } ) {
+ const [ isOpen, setIsOpen ] = useState( false );
+ const onToggle = () => {
+ setIsOpen( ( prev ) => ! prev );
+ };
+ const openOnArrowDown = ( event ) => {
+ if ( ! isOpen && event.keyCode === DOWN ) {
+ event.preventDefault();
+ event.stopPropagation();
+ onToggle();
+ }
+ };
+ return (
+ <>
+ }
+ />
+ { isOpen && (
+
+ ) }
+ >
+ );
+}
+
+export default DuotoneControl;
diff --git a/packages/block-editor/src/components/duotone-control/style.scss b/packages/block-editor/src/components/duotone-control/style.scss
new file mode 100644
index 0000000000000..133f0e0b8a748
--- /dev/null
+++ b/packages/block-editor/src/components/duotone-control/style.scss
@@ -0,0 +1,26 @@
+.block-editor-duotone-control__popover {
+ .components-popover__content {
+ border: $border-width solid $gray-900;
+ min-width: 214px;
+ }
+
+ .components-circular-option-picker {
+ padding: $grid-unit-15;
+ }
+
+ .components-menu-group__label {
+ padding: $grid-unit-15 $grid-unit-15 0 $grid-unit-15;
+ width: 100%;
+ }
+}
+
+.block-editor-duotone-control__popover > .components-popover__content {
+ // Matches 8 swatches in width.
+ width: 334px;
+}
+
+// Better align the popover under the swatch.
+// @todo: when the positioning for popovers gets refactored, this can presumably be removed.
+.block-editor-duotone-control__popover:not([data-y-axis="middle"][data-x-axis="right"]) > .components-popover__content {
+ margin-left: -14px;
+}
diff --git a/packages/block-editor/src/components/index.js b/packages/block-editor/src/components/index.js
index ac7471317acbe..ad27f85c085d8 100644
--- a/packages/block-editor/src/components/index.js
+++ b/packages/block-editor/src/components/index.js
@@ -36,6 +36,7 @@ export { default as ButtonBlockerAppender } from './button-block-appender';
export { default as ColorPalette } from './color-palette';
export { default as ColorPaletteControl } from './color-palette/control';
export { default as ContrastChecker } from './contrast-checker';
+export { default as __experimentalDuotoneControl } from './duotone-control';
export { default as __experimentalGradientPicker } from './gradient-picker';
export { default as __experimentalGradientPickerControl } from './gradient-picker/control';
export { default as __experimentalGradientPickerPanel } from './gradient-picker/panel';
diff --git a/packages/block-editor/src/hooks/duotone.js b/packages/block-editor/src/hooks/duotone.js
new file mode 100644
index 0000000000000..dd2d27a784d7b
--- /dev/null
+++ b/packages/block-editor/src/hooks/duotone.js
@@ -0,0 +1,255 @@
+/**
+ * External dependencies
+ */
+import classnames from 'classnames';
+import tinycolor from 'tinycolor2';
+
+/**
+ * WordPress dependencies
+ */
+import { getBlockSupport, hasBlockSupport } from '@wordpress/blocks';
+import { SVG } from '@wordpress/components';
+import { createHigherOrderComponent, useInstanceId } from '@wordpress/compose';
+import { addFilter } from '@wordpress/hooks';
+
+/**
+ * Internal dependencies
+ */
+import {
+ BlockControls,
+ __experimentalDuotoneControl as DuotoneControl,
+ __experimentalUseEditorFeature as useEditorFeature,
+} from '../components';
+
+/**
+ * Convert a list of colors to an object of R, G, and B values.
+ *
+ * @param {string[]} colors Array of RBG color strings.
+ *
+ * @return {Object} R, G, and B values.
+ */
+export function getValuesFromColors( colors = [] ) {
+ const values = { r: [], g: [], b: [] };
+
+ colors.forEach( ( color ) => {
+ // Access values directly to skip extra rounding that tinycolor.toRgb() does.
+ const tcolor = tinycolor( color );
+ values.r.push( tcolor._r / 255 );
+ values.g.push( tcolor._g / 255 );
+ values.b.push( tcolor._b / 255 );
+ } );
+
+ return values;
+}
+
+/**
+ * Values for the SVG `feComponentTransfer`.
+ *
+ * @typedef Values {Object}
+ * @property {number[]} r Red values.
+ * @property {number[]} g Green values.
+ * @property {number[]} b Blue values.
+ */
+
+/**
+ * SVG and stylesheet needed for rendering the duotone filter.
+ *
+ * @param {Object} props Duotone props.
+ * @param {string} props.selector Selector to apply the filter to.
+ * @param {string} props.id Unique id for this duotone filter.
+ * @param {Values} props.values R, G, and B values to filter with.
+ * @return {WPElement} Duotone element.
+ */
+function DuotoneFilter( { selector, id, values } ) {
+ const stylesheet = `
+${ selector } {
+ filter: url( #${ id } );
+}
+`;
+
+ return (
+ <>
+
+
+ >
+ );
+}
+
+function DuotonePanel( { attributes, setAttributes } ) {
+ const style = attributes?.style;
+ const duotone = style?.color?.duotone;
+
+ const duotonePalette = useEditorFeature( 'color.duotone' );
+ const colorPalette = useEditorFeature( 'color.palette' );
+
+ return (
+
+ {
+ const newStyle = {
+ ...style,
+ color: {
+ ...style?.color,
+ duotone: newDuotone,
+ },
+ };
+ setAttributes( { style: newStyle } );
+ } }
+ />
+
+ );
+}
+
+/**
+ * Filters registered block settings, extending attributes to include
+ * the `duotone` attribute.
+ *
+ * @param {Object} settings Original block settings
+ * @return {Object} Filtered block settings
+ */
+function addDuotoneAttributes( settings ) {
+ if ( ! hasBlockSupport( settings, 'color.__experimentalDuotone' ) ) {
+ return settings;
+ }
+
+ // Allow blocks to specify their own attribute definition with default
+ // values if needed.
+ if ( ! settings.attributes.style ) {
+ Object.assign( settings.attributes, {
+ style: {
+ type: 'object',
+ },
+ } );
+ }
+
+ return settings;
+}
+
+/**
+ * Override the default edit UI to include toolbar controls for duotone if the
+ * block supports duotone.
+ *
+ * @param {Function} BlockEdit Original component
+ * @return {Function} Wrapped component
+ */
+const withDuotoneControls = createHigherOrderComponent(
+ ( BlockEdit ) => ( props ) => {
+ const hasDuotoneSupport = hasBlockSupport(
+ props.name,
+ 'color.__experimentalDuotone'
+ );
+
+ return (
+ <>
+
+ { hasDuotoneSupport && }
+ >
+ );
+ },
+ 'withDuotoneControls'
+);
+
+/**
+ * Override the default block element to include duotone styles.
+ *
+ * @param {Function} BlockListBlock Original component
+ * @return {Function} Wrapped component
+ */
+const withDuotoneStyles = createHigherOrderComponent(
+ ( BlockListBlock ) => ( props ) => {
+ const duotoneSupport = getBlockSupport(
+ props.name,
+ 'color.__experimentalDuotone'
+ );
+ const values = props?.attributes?.style?.color?.duotone;
+
+ if ( ! duotoneSupport || ! values ) {
+ return ;
+ }
+
+ const id = `wp-duotone-filter-${ useInstanceId( BlockListBlock ) }`;
+
+ const selectors = duotoneSupport.split( ',' );
+ const selectorsScoped = selectors.map(
+ ( selector ) => `.${ id } ${ selector.trim() }`
+ );
+ const selectorsGroup = selectorsScoped.join( ', ' );
+
+ const className = classnames( props?.classname, id );
+
+ return (
+ <>
+
+
+ >
+ );
+ },
+ 'withDuotoneStyles'
+);
+
+addFilter(
+ 'blocks.registerBlockType',
+ 'core/editor/duotone/add-attributes',
+ addDuotoneAttributes
+);
+addFilter(
+ 'editor.BlockEdit',
+ 'core/editor/duotone/with-editor-controls',
+ withDuotoneControls
+);
+addFilter(
+ 'editor.BlockListBlock',
+ 'core/editor/duotone/with-styles',
+ withDuotoneStyles
+);
diff --git a/packages/block-editor/src/hooks/index.js b/packages/block-editor/src/hooks/index.js
index c087e994ba9eb..6bf7db445f70b 100644
--- a/packages/block-editor/src/hooks/index.js
+++ b/packages/block-editor/src/hooks/index.js
@@ -7,5 +7,6 @@ import './custom-class-name';
import './generated-class-name';
import './style';
import './color';
+import './duotone';
import './font-size';
import './layout';
diff --git a/packages/block-editor/src/style.scss b/packages/block-editor/src/style.scss
index f80eeefd3be42..f0b7bcaa36342 100644
--- a/packages/block-editor/src/style.scss
+++ b/packages/block-editor/src/style.scss
@@ -31,6 +31,7 @@
@import "./components/colors-gradients/style.scss";
@import "./components/contrast-checker/style.scss";
@import "./components/default-block-appender/style.scss";
+@import "./components/duotone-control/style.scss";
@import "./components/font-appearance-control/style.scss";
@import "./components/justify-content-control/style.scss";
@import "./components/link-control/style.scss";
diff --git a/packages/block-library/src/cover/block.json b/packages/block-library/src/cover/block.json
index b0b2d58bde2aa..676fb581c2b95 100644
--- a/packages/block-library/src/cover/block.json
+++ b/packages/block-library/src/cover/block.json
@@ -56,6 +56,11 @@
"html": false,
"spacing": {
"padding": true
+ },
+ "color": {
+ "__experimentalDuotone": "> .wp-block-cover__image-background, > .wp-block-cover__video-background",
+ "text": false,
+ "background": false
}
},
"editorStyle": "wp-block-cover-editor",
diff --git a/packages/block-library/src/cover/transforms.js b/packages/block-library/src/cover/transforms.js
index 85b340af2e9bb..bb6af0ac319c1 100644
--- a/packages/block-library/src/cover/transforms.js
+++ b/packages/block-library/src/cover/transforms.js
@@ -13,7 +13,7 @@ const transforms = {
{
type: 'block',
blocks: [ 'core/image' ],
- transform: ( { caption, url, align, id, anchor } ) =>
+ transform: ( { caption, url, align, id, anchor, duotone } ) =>
createBlock(
'core/cover',
{
@@ -21,6 +21,7 @@ const transforms = {
align,
id,
anchor,
+ duotone,
},
[
createBlock( 'core/paragraph', {
@@ -76,13 +77,14 @@ const transforms = {
! customGradient
);
},
- transform: ( { title, url, align, id, anchor } ) =>
+ transform: ( { title, url, align, id, anchor, duotone } ) =>
createBlock( 'core/image', {
caption: title,
url,
align,
id,
anchor,
+ duotone,
} ),
},
{
diff --git a/packages/block-library/src/image/block.json b/packages/block-library/src/image/block.json
index 3952230ded6a3..6b9fb6443a236 100644
--- a/packages/block-library/src/image/block.json
+++ b/packages/block-library/src/image/block.json
@@ -72,6 +72,11 @@
},
"supports": {
"anchor": true,
+ "color": {
+ "__experimentalDuotone": "img",
+ "text": false,
+ "background": false
+ },
"__experimentalBorder": {
"radius": true
}
diff --git a/packages/block-library/src/image/index.php b/packages/block-library/src/image/index.php
new file mode 100644
index 0000000000000..8743baebf60fc
--- /dev/null
+++ b/packages/block-library/src/image/index.php
@@ -0,0 +1,45 @@
+ 'render_block_core_image',
+ )
+ );
+}
+add_action( 'init', 'register_block_core_image' );
diff --git a/packages/block-library/src/media-text/block.json b/packages/block-library/src/media-text/block.json
index 0e175cf04bd53..c63b8289e34d5 100644
--- a/packages/block-library/src/media-text/block.json
+++ b/packages/block-library/src/media-text/block.json
@@ -86,6 +86,7 @@
"align": [ "wide", "full" ],
"html": false,
"color": {
+ "duotone": "img, video",
"gradients": true,
"link": true
}
diff --git a/packages/block-library/src/media-text/transforms.js b/packages/block-library/src/media-text/transforms.js
index 0851b6d550421..0990bbc479741 100644
--- a/packages/block-library/src/media-text/transforms.js
+++ b/packages/block-library/src/media-text/transforms.js
@@ -8,24 +8,26 @@ const transforms = {
{
type: 'block',
blocks: [ 'core/image' ],
- transform: ( { alt, url, id, anchor } ) =>
+ transform: ( { alt, url, id, anchor, duotone } ) =>
createBlock( 'core/media-text', {
mediaAlt: alt,
mediaId: id,
mediaUrl: url,
mediaType: 'image',
anchor,
+ duotone,
} ),
},
{
type: 'block',
blocks: [ 'core/video' ],
- transform: ( { src, id, anchor } ) =>
+ transform: ( { src, id, anchor, duotone } ) =>
createBlock( 'core/media-text', {
mediaId: id,
mediaUrl: src,
mediaType: 'video',
anchor,
+ duotone,
} ),
},
],
@@ -36,12 +38,13 @@ const transforms = {
isMatch: ( { mediaType, mediaUrl } ) => {
return ! mediaUrl || mediaType === 'image';
},
- transform: ( { mediaAlt, mediaId, mediaUrl, anchor } ) => {
+ transform: ( { mediaAlt, mediaId, mediaUrl, anchor, duotone } ) => {
return createBlock( 'core/image', {
alt: mediaAlt,
id: mediaId,
url: mediaUrl,
anchor,
+ duotone,
} );
},
},
@@ -51,11 +54,12 @@ const transforms = {
isMatch: ( { mediaType, mediaUrl } ) => {
return ! mediaUrl || mediaType === 'video';
},
- transform: ( { mediaId, mediaUrl, anchor } ) => {
+ transform: ( { mediaId, mediaUrl, anchor, duotone } ) => {
return createBlock( 'core/video', {
id: mediaId,
src: mediaUrl,
anchor,
+ duotone,
} );
},
},
diff --git a/packages/block-library/src/video/block.json b/packages/block-library/src/video/block.json
index 9f8b569d239b2..a3cee6eabc410 100644
--- a/packages/block-library/src/video/block.json
+++ b/packages/block-library/src/video/block.json
@@ -71,7 +71,12 @@
},
"supports": {
"anchor": true,
- "align": true
+ "align": true,
+ "color": {
+ "duotone": "video",
+ "text": false,
+ "background": false
+ }
},
"editorStyle": "wp-block-video-editor",
"style": "wp-block-video"
diff --git a/packages/components/src/color-list-picker/index.js b/packages/components/src/color-list-picker/index.js
new file mode 100644
index 0000000000000..7550cb8b12b1c
--- /dev/null
+++ b/packages/components/src/color-list-picker/index.js
@@ -0,0 +1,56 @@
+/**
+ * WordPress dependencies
+ */
+import { useState } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import Button from '../button';
+import ColorPalette from '../color-palette';
+import Swatch from '../swatch';
+
+function ColorOption( { label, value, colors, onChange } ) {
+ const [ isOpen, setIsOpen ] = useState( false );
+ return (
+ <>
+ }
+ onClick={ () => setIsOpen( ( prev ) => ! prev ) }
+ >
+ { label }
+
+ { isOpen && (
+
+ ) }
+ >
+ );
+}
+
+function ColorListPicker( { colors, labels, value = [], onChange } ) {
+ return (
+
+ { labels.map( ( label, index ) => (
+ {
+ const newColors = value.slice();
+ newColors[ index ] = newColor;
+ onChange( newColors );
+ } }
+ />
+ ) ) }
+
+ );
+}
+
+export default ColorListPicker;
diff --git a/packages/components/src/color-list-picker/style.scss b/packages/components/src/color-list-picker/style.scss
new file mode 100644
index 0000000000000..09a8e7348066c
--- /dev/null
+++ b/packages/components/src/color-list-picker/style.scss
@@ -0,0 +1,4 @@
+.components-color-list-picker,
+.components-color-list-picker__swatch-button {
+ width: 100%;
+}
diff --git a/packages/components/src/custom-gradient-bar/control-points.js b/packages/components/src/custom-gradient-bar/control-points.js
index d8d5ec16c2fa7..5bec42a2f2ae6 100644
--- a/packages/components/src/custom-gradient-bar/control-points.js
+++ b/packages/components/src/custom-gradient-bar/control-points.js
@@ -109,6 +109,8 @@ function ControlPointButton( {
}
function ControlPoints( {
+ disableRemove,
+ disableAlpha,
gradientPickerDomRef,
ignoreMarkerPosition,
value: controlPoints,
@@ -223,6 +225,7 @@ function ControlPoints( {
renderContent={ ( { onClose } ) => (
<>
{
onChange(
@@ -234,21 +237,23 @@ function ControlPoints( {
);
} }
/>
-
+ { ! disableRemove && (
+
+ ) }
>
) }
popoverProps={ COLOR_POPOVER_PROPS }
@@ -264,6 +269,7 @@ function InsertPoint( {
onOpenInserter,
onCloseInserter,
insertPosition,
+ disableAlpha,
} ) {
const [ alreadyInsertedPoint, setAlreadyInsertedPoint ] = useState( false );
return (
@@ -297,6 +303,7 @@ function InsertPoint( {
) }
renderContent={ () => (
{
if ( ! alreadyInsertedPoint ) {
onChange(
diff --git a/packages/components/src/custom-gradient-bar/index.js b/packages/components/src/custom-gradient-bar/index.js
index bd8b1922cdf6e..823e3d00992a3 100644
--- a/packages/components/src/custom-gradient-bar/index.js
+++ b/packages/components/src/custom-gradient-bar/index.js
@@ -76,6 +76,8 @@ export default function CustomGradientBar( {
hasGradient,
value: controlPoints,
onChange,
+ disableInserter = false,
+ disableAlpha = false,
} ) {
const gradientPickerDomRef = useRef();
@@ -129,24 +131,28 @@ export default function CustomGradientBar( {
onMouseLeave={ onMouseLeave }
>
- { ( isMovingInserter || isInsertingControlPoint ) && (
-
{
- gradientBarStateDispatch( {
- type: 'OPEN_INSERTER',
- } );
- } }
- onCloseInserter={ () => {
- gradientBarStateDispatch( {
- type: 'CLOSE_INSERTER',
- } );
- } }
- />
- ) }
+ { ! disableInserter &&
+ ( isMovingInserter || isInsertingControlPoint ) && (
+ {
+ gradientBarStateDispatch( {
+ type: 'OPEN_INSERTER',
+ } );
+ } }
+ onCloseInserter={ () => {
+ gradientBarStateDispatch( {
+ type: 'CLOSE_INSERTER',
+ } );
+ } }
+ />
+ ) }
{
+ const newValue = getColorsFromColorStops( newColorStops );
+ onChange( newValue );
+ } }
+ />
+ );
+}
diff --git a/packages/components/src/duotone-picker/duotone-picker.js b/packages/components/src/duotone-picker/duotone-picker.js
new file mode 100644
index 0000000000000..fb15f12162559
--- /dev/null
+++ b/packages/components/src/duotone-picker/duotone-picker.js
@@ -0,0 +1,92 @@
+/**
+ * External dependencies
+ */
+import { isEqual } from 'lodash';
+
+/**
+ * WordPress dependencies
+ */
+import { useMemo } from '@wordpress/element';
+import { __, sprintf } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import ColorListPicker from '../color-list-picker';
+import CircularOptionPicker from '../circular-option-picker';
+
+import CustomDuotoneBar from './custom-duotone-bar';
+import { getDefaultColors, getGradientFromCSSColors } from './utils';
+
+function DuotonePicker( { colorPalette, duotonePalette, value, onChange } ) {
+ const [ defaultDark, defaultLight ] = useMemo(
+ () => getDefaultColors( colorPalette ),
+ [ colorPalette ]
+ );
+ return (
+ {
+ const style = {
+ background: getGradientFromCSSColors( colors, '135deg' ),
+ color: 'transparent',
+ };
+ const tooltipText =
+ name ??
+ sprintf(
+ // translators: %s: duotone code e.g: "dark-grayscale" or "7f7f7f-ffffff".
+ __( 'Duotone code: %s' ),
+ slug
+ );
+ const label = name
+ ? sprintf(
+ // translators: %s: The name of the option e.g: "Dark grayscale".
+ __( 'Duotone: %s' ),
+ name
+ )
+ : tooltipText;
+ const isSelected = isEqual( colors, value );
+
+ return (
+ {
+ onChange( isSelected ? undefined : colors );
+ } }
+ />
+ );
+ } ) }
+ actions={
+ onChange( undefined ) }
+ >
+ { __( 'Clear' ) }
+
+ }
+ >
+
+ {
+ if ( ! newColors[ 0 ] ) {
+ newColors[ 0 ] = defaultDark;
+ }
+ if ( ! newColors[ 1 ] ) {
+ newColors[ 1 ] = defaultLight;
+ }
+ const newValue =
+ newColors.length >= 2 ? newColors : undefined;
+ onChange( newValue );
+ } }
+ />
+
+ );
+}
+
+export default DuotonePicker;
diff --git a/packages/components/src/duotone-picker/duotone-swatch.js b/packages/components/src/duotone-picker/duotone-swatch.js
new file mode 100644
index 0000000000000..1ab35b53034c7
--- /dev/null
+++ b/packages/components/src/duotone-picker/duotone-swatch.js
@@ -0,0 +1,16 @@
+/**
+ * Internal dependencies
+ */
+import Swatch from '../swatch';
+
+import { getGradientFromCSSColors } from './utils';
+
+function DuotoneSwatch( { values } ) {
+ return (
+
+ );
+}
+
+export default DuotoneSwatch;
diff --git a/packages/components/src/duotone-picker/index.js b/packages/components/src/duotone-picker/index.js
new file mode 100644
index 0000000000000..0043b43902900
--- /dev/null
+++ b/packages/components/src/duotone-picker/index.js
@@ -0,0 +1,2 @@
+export { default as DuotonePicker } from './duotone-picker';
+export { default as DuotoneSwatch } from './duotone-swatch';
diff --git a/packages/components/src/duotone-picker/utils.js b/packages/components/src/duotone-picker/utils.js
new file mode 100644
index 0000000000000..72c607072e8c6
--- /dev/null
+++ b/packages/components/src/duotone-picker/utils.js
@@ -0,0 +1,84 @@
+/**
+ * External dependencies
+ */
+import tinycolor from 'tinycolor2';
+
+/**
+ * Object representation for a color.
+ *
+ * @typedef {Object} RGBColor
+ * @property {number} r Red component of the color in the range [0,1].
+ * @property {number} g Green component of the color in the range [0,1].
+ * @property {number} b Blue component of the color in the range [0,1].
+ */
+
+/**
+ * Calculate the brightest and darkest values from a color palette.
+ *
+ * @param {Object[]} palette Color palette for the theme.
+ *
+ * @return {string[]} Tuple of the darkest color and brightest color.
+ */
+export function getDefaultColors( palette ) {
+ // A default dark and light color are required.
+ if ( ! palette || palette.length < 2 ) return [ '#000', '#fff' ];
+
+ return palette
+ .map( ( { color } ) => ( {
+ color,
+ brightness: tinycolor( color ).getBrightness() / 255,
+ } ) )
+ .reduce(
+ ( [ min, max ], current ) => {
+ return [
+ current.brightness <= min.brightness ? current : min,
+ current.brightness >= max.brightness ? current : max,
+ ];
+ },
+ [ { brightness: 1 }, { brightness: 0 } ]
+ )
+ .map( ( { color } ) => color );
+}
+
+/**
+ * Generate a duotone gradient from a list of colors.
+ *
+ * @param {string[]} colors CSS color strings.
+ * @param {string} angle CSS gradient angle.
+ *
+ * @return {string} CSS gradient string for the duotone swatch.
+ */
+export function getGradientFromCSSColors( colors = [], angle = '90deg' ) {
+ const l = 100 / colors.length;
+
+ const stops = colors
+ .map( ( c, i ) => `${ c } ${ i * l }%, ${ c } ${ ( i + 1 ) * l }%` )
+ .join( ', ' );
+
+ return `linear-gradient( ${ angle }, ${ stops } )`;
+}
+
+/**
+ * Convert a color array to an array of color stops.
+ *
+ * @param {string[]} colors CSS colors array
+ *
+ * @return {Object[]} Color stop information.
+ */
+export function getColorStopsFromColors( colors ) {
+ return colors.map( ( color, i ) => ( {
+ position: ( i * 100 ) / ( colors.length - 1 ),
+ color,
+ } ) );
+}
+
+/**
+ * Convert a color stop array to an array colors.
+ *
+ * @param {Object[]} colorStops Color stop information.
+ *
+ * @return {string[]} CSS colors array.
+ */
+export function getColorsFromColorStops( colorStops = [] ) {
+ return colorStops.map( ( { color } ) => color );
+}
diff --git a/packages/components/src/index.js b/packages/components/src/index.js
index 0c67e9330f035..efbfcce172359 100644
--- a/packages/components/src/index.js
+++ b/packages/components/src/index.js
@@ -59,6 +59,7 @@ export {
} from './drop-zone/provider';
export { default as Dropdown } from './dropdown';
export { default as DropdownMenu } from './dropdown-menu';
+export { DuotoneSwatch, DuotonePicker } from './duotone-picker';
export { default as ExternalLink } from './external-link';
export { default as Flex } from './flex';
export { default as FlexBlock } from './flex/block';
diff --git a/packages/components/src/style.scss b/packages/components/src/style.scss
index a7b92c1192b13..13c2049b894bf 100644
--- a/packages/components/src/style.scss
+++ b/packages/components/src/style.scss
@@ -8,6 +8,7 @@
@import "./color-indicator/style.scss";
@import "./color-picker/style.scss";
@import "./combobox-control/style.scss";
+@import "./color-list-picker/style.scss";
@import "./custom-gradient-picker/style.scss";
@import "./custom-select-control/style.scss";
@import "./date-time/style.scss";
@@ -36,6 +37,7 @@
@import "./scroll-lock/style.scss";
@import "./select-control/style.scss";
@import "./snackbar/style.scss";
+@import "./swatch/style.scss";
@import "./tab-panel/style.scss";
@import "./text-control/style.scss";
@import "./tip/style.scss";
diff --git a/packages/components/src/swatch/index.js b/packages/components/src/swatch/index.js
new file mode 100644
index 0000000000000..1d86ace0ec773
--- /dev/null
+++ b/packages/components/src/swatch/index.js
@@ -0,0 +1,19 @@
+/**
+ * WordPress dependencies
+ */
+import { swatch } from '@wordpress/icons';
+
+/**
+ * Internal dependencies
+ */
+import Icon from '../icon';
+
+function Swatch( { fill } ) {
+ return fill ? (
+
+ ) : (
+
+ );
+}
+
+export default Swatch;
diff --git a/packages/components/src/swatch/style.scss b/packages/components/src/swatch/style.scss
new file mode 100644
index 0000000000000..b18b361f39d65
--- /dev/null
+++ b/packages/components/src/swatch/style.scss
@@ -0,0 +1,21 @@
+.components-swatch {
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ color: transparent;
+ background: transparent;
+
+ // Regular border doesn't seem to work in the toolbar button, but pseudo-selector border does.
+ &::after {
+ content: "";
+ display: block;
+ width: 100%;
+ height: 100%;
+ border: $border-width solid rgba(0, 0, 0, 0.2);
+ border-radius: 50%;
+ }
+}
+
+.components-button.has-icon.has-text .components-swatch {
+ margin-right: $grid-unit;
+}
diff --git a/packages/icons/src/index.js b/packages/icons/src/index.js
index 3fa3853b61568..dfb71e79e6d6c 100644
--- a/packages/icons/src/index.js
+++ b/packages/icons/src/index.js
@@ -187,6 +187,7 @@ export { default as shipping } from './library/shipping';
export { default as stretchWide } from './library/stretch-wide';
export { default as subscript } from './library/subscript';
export { default as superscript } from './library/superscript';
+export { default as swatch } from './library/swatch';
export { default as tableColumnAfter } from './library/table-column-after';
export { default as tableColumnBefore } from './library/table-column-before';
export { default as tableColumnDelete } from './library/table-column-delete';
diff --git a/packages/icons/src/library/swatch.js b/packages/icons/src/library/swatch.js
new file mode 100644
index 0000000000000..1d9a63e84838e
--- /dev/null
+++ b/packages/icons/src/library/swatch.js
@@ -0,0 +1,12 @@
+/**
+ * WordPress dependencies
+ */
+import { Path, SVG } from '@wordpress/primitives';
+
+const swatch = (
+
+);
+
+export default swatch;
diff --git a/phpunit/class-wp-theme-json-resolver-test.php b/phpunit/class-wp-theme-json-resolver-test.php
index 9e9d298ab6666..f840f07f4a164 100644
--- a/phpunit/class-wp-theme-json-resolver-test.php
+++ b/phpunit/class-wp-theme-json-resolver-test.php
@@ -84,6 +84,11 @@ function test_fields_are_extracted() {
'key' => 'name',
'context' => 'Gradient name',
),
+ array(
+ 'path' => array( 'settings', '*', 'color', 'duotone' ),
+ 'key' => 'name',
+ 'context' => 'Duotone name',
+ ),
array(
'path' => array( 'customTemplates' ),
'key' => 'title',