-
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
Block Editor: Implement new colors hook. #16781
Changes from 11 commits
652a37d
5c10afe
ead8a38
7809eaf
ee203c8
3316f3b
9dcbb9f
86efb01
69865dd
b3d600e
81c60f0
25053b5
fa6c34f
da24e6c
90990ce
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 |
---|---|---|
@@ -0,0 +1,205 @@ | ||
/** | ||
* External dependencies | ||
*/ | ||
import memoize from 'memize'; | ||
import { kebabCase, camelCase, startCase } from 'lodash'; | ||
|
||
/** | ||
* WordPress dependencies | ||
*/ | ||
import { __ } from '@wordpress/i18n'; | ||
import { useSelect, useDispatch } from '@wordpress/data'; | ||
import { | ||
useCallback, | ||
useMemo, | ||
Children, | ||
cloneElement, | ||
} from '@wordpress/element'; | ||
|
||
/** | ||
* Internal dependencies | ||
*/ | ||
import PanelColorSettings from '../panel-color-settings'; | ||
import ContrastChecker from '../contrast-checker'; | ||
import InspectorControls from '../inspector-controls'; | ||
import { useBlockEditContext } from '../block-edit'; | ||
|
||
const ColorPanel = ( { | ||
title, | ||
colorSettings, | ||
colorPanelProps, | ||
contrastCheckerProps, | ||
components, | ||
panelChildren, | ||
} ) => ( | ||
<PanelColorSettings | ||
title={ title } | ||
initialOpen={ false } | ||
colorSettings={ colorSettings } | ||
{ ...colorPanelProps } | ||
> | ||
{ contrastCheckerProps && | ||
components.map( ( Component ) => ( | ||
<ContrastChecker | ||
key={ Component.displayName } | ||
textColor={ Component.color } | ||
{ ...contrastCheckerProps } | ||
/> | ||
) ) } | ||
{ typeof panelChildren === 'function' ? | ||
panelChildren( components ) : | ||
panelChildren } | ||
</PanelColorSettings> | ||
); | ||
const InspectorControlsColorPanel = ( props ) => ( | ||
<InspectorControls> | ||
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 guess we don't need to assume the panel is nested inside InspectorControls and we can left the choice for the user. 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. Where else would you render it? 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. In a placeholder for example as part of a flow to initialize a block. 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 would rather expose that under |
||
<ColorPanel { ...props } /> | ||
</InspectorControls> | ||
); | ||
|
||
export default function __experimentalUseColors( | ||
colorConfigs, | ||
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 wonder if we should have a useColors or a useColor hook used multiple times. 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. Plural, because we want one panel inspector controls per list of colors, not per color. |
||
{ | ||
panelTitle = __( 'Color Settings' ), | ||
colorPanelProps, | ||
contrastCheckerProps, | ||
panelChildren, | ||
} = { | ||
panelTitle: __( 'Color Settings' ), | ||
}, | ||
deps = [] | ||
) { | ||
const { clientId } = useBlockEditContext(); | ||
const { attributes, settingsColors } = useSelect( | ||
( select ) => { | ||
const { getBlockAttributes, getSettings } = select( 'core/block-editor' ); | ||
return { | ||
attributes: getBlockAttributes( clientId ), | ||
settingsColors: getSettings().colors, | ||
}; | ||
}, | ||
[ clientId ] | ||
); | ||
const { updateBlockAttributes } = useDispatch( 'core/block-editor' ); | ||
const setAttributes = useCallback( | ||
( newAttributes ) => updateBlockAttributes( clientId, newAttributes ), | ||
[ updateBlockAttributes, clientId ] | ||
); | ||
|
||
const createComponent = useMemo( | ||
() => | ||
memoize( | ||
( attribute, color, colorValue, customColor ) => ( { children } ) => | ||
// Clone children, setting the style attribute from the color configuration, | ||
// if not already set explicitly through props. | ||
Children.map( children, ( child ) => { | ||
let className = child.props.className; | ||
let style = child.props.style; | ||
if ( color ) { | ||
className = `${ child.props.className } has-${ kebabCase( | ||
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. We have a small function that computes the class name from the color slug 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 one works slightly differently. It takes a kebab cased context name and the slug. Here we have both the color and attribute name in camel case and need to convert one or both depending on whether it's a custom color or not. |
||
color | ||
) }-${ kebabCase( attribute ) }`; | ||
style = { [ attribute ]: colorValue, ...child.props.style }; | ||
} else if ( customColor ) { | ||
className = `${ child.props.className } has-${ kebabCase( attribute ) }`; | ||
style = { [ attribute ]: customColor, ...child.props.style }; | ||
} | ||
return cloneElement( child, { | ||
className, | ||
style, | ||
} ); | ||
} ), | ||
{ maxSize: colorConfigs.length } | ||
), | ||
[ colorConfigs.length ] | ||
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 take it that relying on I can see why it works, but it's strictly speaking not correct. Just noting it here. 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. For each use of the hook we need a |
||
); | ||
const createSetColor = useMemo( | ||
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.
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. To avoid calling |
||
() => | ||
memoize( | ||
( name, colors ) => ( newColor ) => { | ||
const color = colors.find( ( _color ) => _color.color === newColor ); | ||
setAttributes( { | ||
[ color ? camelCase( `custom ${ name }` ) : name ]: undefined, | ||
} ); | ||
setAttributes( { | ||
[ color ? name : camelCase( `custom ${ name }` ) ]: color ? | ||
color.slug : | ||
newColor, | ||
} ); | ||
}, | ||
{ | ||
maxSize: colorConfigs.length, | ||
} | ||
), | ||
[ setAttributes, colorConfigs.length ] | ||
); | ||
|
||
return useMemo( () => { | ||
const colorSettings = []; | ||
|
||
const components = colorConfigs.reduce( ( acc, colorConfig ) => { | ||
if ( typeof colorConfig === 'string' ) { | ||
colorConfig = { name: colorConfig }; | ||
} | ||
const { | ||
name, // E.g. 'backgroundColor'. | ||
attribute = name, // E.g. 'backgroundColor'. | ||
|
||
panelLabel = startCase( name ), // E.g. 'Background Color'. | ||
componentName = panelLabel.replace( /\s/g, '' ), // E.g. 'BackgroundColor'. | ||
|
||
color = colorConfig.color, | ||
colors = settingsColors, | ||
} = { | ||
...colorConfig, | ||
color: attributes[ colorConfig.name ], | ||
}; | ||
|
||
// We memoize the non-primitives to avoid unnecessary updates | ||
// when they are used as props for other components. | ||
const _color = colors.find( ( __color ) => __color.slug === color ); | ||
acc[ componentName ] = createComponent( | ||
attribute, | ||
color, | ||
_color && _color.color, | ||
attributes[ camelCase( `custom ${ name }` ) ] | ||
); | ||
acc[ componentName ].displayName = componentName; | ||
acc[ componentName ].color = color; | ||
acc[ componentName ].setColor = createSetColor( name, colors ); | ||
|
||
const newSettingIndex = | ||
colorSettings.push( { | ||
value: color, | ||
onChange: acc[ componentName ].setColor, | ||
label: panelLabel, | ||
colors, | ||
} ) - 1; | ||
// These settings will be spread over the `colors` in | ||
// `colorPanelProps`, so we need to unset the key here, | ||
// if not set to an actual value, to avoid overwriting | ||
// an actual value in `colorPanelProps`. | ||
if ( ! colors ) { | ||
delete colorSettings[ newSettingIndex ].colors; | ||
} | ||
|
||
return acc; | ||
}, {} ); | ||
|
||
const wrappedColorPanelProps = { | ||
title: panelTitle, | ||
colorSettings, | ||
colorPanelProps, | ||
contrastCheckerProps, | ||
components: Object.values( components ), | ||
panelChildren, | ||
}; | ||
return { | ||
...components, | ||
ColorPanel: <ColorPanel { ...wrappedColorPanelProps } />, | ||
InspectorControlsColorPanel: ( | ||
<InspectorControlsColorPanel { ...wrappedColorPanelProps } /> | ||
), | ||
}; | ||
}, [ attributes, setAttributes, ...deps ] ); | ||
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 color config be a dependency here? colorConfig may totally change the expected output of the component. I guess we did not added it as a dependency because if users pass an array inline on each re-render a new reference is passed, could we automatically generate the dependencies from the config e.g: generate an array of [ name, attribute, name, attribute ...], If we also pass all properties of panel setting props we could avoid the need for dependencies. Currently, we are relying on devs passing the dependencies and I think it will be something developers will easily miss. 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.
|
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -8,7 +8,7 @@ export { default as AlignmentToolbar } from './alignment-toolbar'; | |
export { default as Autocomplete } from './autocomplete'; | ||
export { default as BlockAlignmentToolbar } from './block-alignment-toolbar'; | ||
export { default as BlockControls } from './block-controls'; | ||
export { default as BlockEdit } from './block-edit'; | ||
export { default as BlockEdit, useBlockEditContext } from './block-edit'; | ||
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 add this export also to the native version of the file packages/block-editor/src/components/index.native.js? Otherwise, headings on mobile will break. 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. Yes, good catch! |
||
export { default as BlockFormatControls } from './block-format-controls'; | ||
export { default as BlockIcon } from './block-icon'; | ||
export { default as BlockNavigationDropdown } from './block-navigation/dropdown'; | ||
|
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -13,48 +13,30 @@ import HeadingToolbar from './heading-toolbar'; | |||||
*/ | ||||||
import { __ } from '@wordpress/i18n'; | ||||||
import { PanelBody } from '@wordpress/components'; | ||||||
import { compose } from '@wordpress/compose'; | ||||||
import { createBlock } from '@wordpress/blocks'; | ||||||
import { | ||||||
AlignmentToolbar, | ||||||
BlockControls, | ||||||
InspectorControls, | ||||||
RichText, | ||||||
withColors, | ||||||
PanelColorSettings, | ||||||
__experimentalUseColors, | ||||||
} from '@wordpress/block-editor'; | ||||||
import { memo } from '@wordpress/element'; | ||||||
|
||||||
const HeadingColorUI = memo( | ||||||
function( { | ||||||
textColorValue, | ||||||
setTextColor, | ||||||
} ) { | ||||||
return ( | ||||||
<PanelColorSettings | ||||||
title={ __( 'Color Settings' ) } | ||||||
initialOpen={ false } | ||||||
colorSettings={ [ | ||||||
{ | ||||||
value: textColorValue, | ||||||
onChange: setTextColor, | ||||||
label: __( 'Text Color' ), | ||||||
}, | ||||||
] } | ||||||
/> | ||||||
); | ||||||
} | ||||||
); | ||||||
|
||||||
function HeadingEdit( { | ||||||
attributes, | ||||||
setAttributes, | ||||||
mergeBlocks, | ||||||
onReplace, | ||||||
className, | ||||||
textColor, | ||||||
setTextColor, | ||||||
} ) { | ||||||
const { TextColor, InspectorControlsColorPanel } = __experimentalUseColors( | ||||||
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 work, let's try to open follow-up PRs to use in other blocks to see how it behaves, if it's good enough. 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. What does 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. It applies the text color as inline style to the wrapped component. 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. Why not pass the style directly to the element? 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. It's less verbose this way. 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'm on the fence personally, if it's just about "style", I guess being explicit is better (passing styles). But I believe it also handles classNames and merging these props which ultiimately can result in a lot of duplicated code. 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. It looks a bit like magic right now. It's not very obvious what it does. I'll look around sometime to see how it works. 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.
Yes |
||||||
[ { name: 'textColor', attribute: 'color' } ], | ||||||
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. At the start, our colors API explicitly made block developers identify what was the attribute and what was the CSS property: gutenberg/core-blocks/button/index.js Lines 93 to 94 in b8ee131
People provided feedback in reviews that maybe we could just use something in this format { textColor: 'color' }, where the key represents the Gutenberg attribute and the value of the CSS context/property where the color is used. Would not this format fit here? What were the reasons for the change and go back to explicitly identify the Gutenberg attribute and CSS property? We have functions and docs where we call the CSS property 'color' the color context. And currently, some code that exists outside of the hook calls 'textColor' colorAttributeName. I think naming 'color' attribute may be confusing. Could we keep the current name 'context'? 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.
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. Hi @epiqueras, The problem is that the term attribute in Gutenberg has a very specific meaning. I think people will get confused. 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. You're right, |
||||||
{ | ||||||
contrastCheckerProps: { backgroundColor: 'white', isLargeText: true }, | ||||||
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. How do you decide which color from the first argument to use in the color contrast checks? 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. It checks all of them. 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 think we should not check the contrast of all the colors. Imagining a scenario where we use the hook with background color and text color, the contrast checked should not check the background color. 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.
They can separate it into two hook calls or provide their own custom
Nothing stops that
I think that sort of contrast checking behavior belongs in another hook in another PR. 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 on the heading we use a hardcoded value for background on contracts checking of white? By default, the default WordPress theme does not contain a white background. 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. Right, I changed it to detect the background of the nearest parent that has one. |
||||||
}, | ||||||
[] | ||||||
); | ||||||
|
||||||
const { align, content, level, placeholder } = attributes; | ||||||
const tagName = 'h' + level; | ||||||
|
||||||
|
@@ -71,43 +53,35 @@ function HeadingEdit( { | |||||
<p>{ __( 'Level' ) }</p> | ||||||
<HeadingToolbar isCollapsed={ false } minLevel={ 1 } maxLevel={ 7 } selectedLevel={ level } onChange={ ( newLevel ) => setAttributes( { level: newLevel } ) } /> | ||||||
</PanelBody> | ||||||
<HeadingColorUI | ||||||
setTextColor={ setTextColor } | ||||||
textColorValue={ textColor.color } | ||||||
/> | ||||||
</InspectorControls> | ||||||
<RichText | ||||||
identifier="content" | ||||||
tagName={ tagName } | ||||||
value={ content } | ||||||
onChange={ ( value ) => setAttributes( { content: value } ) } | ||||||
onMerge={ mergeBlocks } | ||||||
onSplit={ ( value ) => { | ||||||
if ( ! value ) { | ||||||
return createBlock( 'core/paragraph' ); | ||||||
} | ||||||
{ InspectorControlsColorPanel } | ||||||
<TextColor> | ||||||
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. It seems this component would also be useful in the save function as the classes/styles we should add follow the same logic. Do you think it would be possible to have something identical there? 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. Yeah, that would be nice, but first we need to mock the React dispatcher in our serializer so that hooks don't throw errors. |
||||||
<RichText | ||||||
identifier="content" | ||||||
tagName={ tagName } | ||||||
value={ content } | ||||||
onChange={ ( value ) => setAttributes( { content: value } ) } | ||||||
onMerge={ mergeBlocks } | ||||||
onSplit={ ( value ) => { | ||||||
if ( ! value ) { | ||||||
return createBlock( 'core/paragraph' ); | ||||||
} | ||||||
|
||||||
return createBlock( 'core/heading', { | ||||||
...attributes, | ||||||
content: value, | ||||||
} ); | ||||||
} } | ||||||
onReplace={ onReplace } | ||||||
onRemove={ () => onReplace( [] ) } | ||||||
className={ classnames( className, { | ||||||
[ `has-text-align-${ align }` ]: align, | ||||||
'has-text-color': textColor.color, | ||||||
[ textColor.class ]: textColor.class, | ||||||
} ) } | ||||||
placeholder={ placeholder || __( 'Write heading…' ) } | ||||||
style={ { | ||||||
color: textColor.color, | ||||||
} } | ||||||
/> | ||||||
return createBlock( 'core/heading', { | ||||||
...attributes, | ||||||
content: value, | ||||||
} ); | ||||||
} } | ||||||
onReplace={ onReplace } | ||||||
onRemove={ () => onReplace( [] ) } | ||||||
className={ classnames( className, { | ||||||
[ `has-text-align-${ align }` ]: align, | ||||||
} ) } | ||||||
placeholder={ placeholder || __( 'Write heading…' ) } | ||||||
/> | ||||||
</TextColor> | ||||||
</> | ||||||
); | ||||||
} | ||||||
|
||||||
export default compose( [ | ||||||
withColors( 'backgroundColor', { textColor: 'color' } ), | ||||||
] )( HeadingEdit ); | ||||||
export default HeadingEdit; |
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 don't think we need the changes in this file, since you can access
clientId
from the context, you can callupdateBlockAttributes
andgetBlockAttributes
without extra context values.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.
That wouldn't subscribe the consumer to changes though, right?
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.
useSelect
wouldThere 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.
Oh right. I was thinking of the Block API functions. I'll change it.
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.
It just feels a lot clunkier to have to use the context to get the
id
and then pass it touseSelect
.Don't you think a
useBlockEditContext
with attributes and the setter will be very useful for people building custom hooks that need access to attributes?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 don't think so personally, I feel like the context should contain the minimum possible things because
if we add these, the question becomes: Will we add a new value there each time we need another selector/action?
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 don't think we could, because it would be limited to the props that
BlockEdit
receives. I just tend to favor context over selector/subscription boilerplate, but I can see why this aligns more with the rest of the codebase and see the value in having a single way of doing things:69865dd