diff --git a/docs/source/blocks/block-style-wrapper.md b/docs/source/blocks/block-style-wrapper.md index e3e825207e..b4cc1ece45 100644 --- a/docs/source/blocks/block-style-wrapper.md +++ b/docs/source/blocks/block-style-wrapper.md @@ -11,6 +11,9 @@ myst: # Block style wrapper +```{versionadded} 16.0.0 +``` + The block style wrapper is part of a block anatomy. It allows you to inject styles from the block schema into the block wrapper in the form of class names. It wraps the block edit and the view components. @@ -109,7 +112,7 @@ config.blocks.blocksConfig.listing.schemaEnhancer = composeSchema( The `styles` field is mapped to an `objectWidget`. The following is an example of a possible set of styles. -The style wrapper will read the styles and inject them into the edit and view components as shown in the next sections. +The style wrapper will read the styles and inject the resultant class names into the edit and view components as shown in the next sections. ```json { @@ -127,7 +130,7 @@ The style wrapper will read the styles and inject them into the edit and view co ## Using the injected class names in your block The resultant class names are injected as a `className` prop into the wrapped block. -Thus you can use it in the root component of your block view and edit components as follows: +Thus you can use it in the root component of your block view component as follows: ```jsx const BlockView = (props)=> ( @@ -149,6 +152,8 @@ The resultant HTML would be the following: ``` Then it's at your discretion how you define the CSS class names in your theme. +## Customize the injected class names + If you need other style of classnames generated, you can use the classname converters defined in `config.settings.styleClassNameConverters`, by registering fieldnames suffixed with the converter name. For example, a style @@ -165,6 +170,72 @@ data like: will generate classnames `primary inverted` +## Inject custom CSS properties + +```{versionadded} 17.8.0 +``` + +The style wrapper also allows to inject custom CSS properties. +This is useful where the property that you want to inject is customizable per project. +For example, imagine you have an existing global CSS custom property `--image-aspect-ratio` that you use for all images in all blocks. +Then in your customized theme, you could set CSS attributes for this property, changing it in runtime. +The key feature of custom CSS properties is that they can be applied also using the cascade. +This means that they can be placed anywhere in either CSS definitions or HTML structure, and they will be applied only in the context where they are defined. + +As an example, first define the style of a teaser block image as follows. + +```css +.block.teaser img { + aspect-ratio: var(--image-aspect-ratio, 16 / 9); +} +``` + +Next, you can enhance a block's schema by injecting the custom CSS property as follows. + +```js + schema.properties.styles.schema.fieldsets[0].fields = [ + ...schema.properties.styles.schema.fieldsets[0].fields, + '--image-aspect-ratio', + ]; + + // We use a select widget and set a default + schema.properties.styles.schema.properties['--image-aspect-ratio'] = { + widget: 'select', + title: 'Aspect Ratio', + choices: [ + ['1', '1:1'], + ['16 / 9', '16/9'], + ], + default: '1', + }; +``` + +Finally, assuming that you select the default value for the {guilabel}`Aspect Ratio` for the custom CSS property, then the markup of the block will contain the custom property as shown. + +```html +
+ +
+``` + +The custom CSS property definition will only apply within the div that it's defined. +As you can see, the custom CSS property applies only within the `div` in which it is defined. + +If you want to use it in your custom components, you need to apply it in the root of your block's view component as follows: + +```jsx +const BlockView = (props)=> ( +
+ // Block's view component code +
+) +``` + +```{note} +You need to manually add the above code in your view component block code to benefit from the style injection. +The styles in the block edit component are injected automatically into the blocks engine editor wrappers, so you don't have to take any action. +``` + ## Align class injection There is an automatic class name injection happening at the same time the style wrapper class names injection. diff --git a/packages/volto/news/5581.feature b/packages/volto/news/5581.feature new file mode 100644 index 0000000000..8e900f8d86 --- /dev/null +++ b/packages/volto/news/5581.feature @@ -0,0 +1 @@ +Added support for custom CSS properties in the `StyleWrapper`. @sneridagh diff --git a/packages/volto/src/components/manage/Blocks/Block/EditBlockWrapper.jsx b/packages/volto/src/components/manage/Blocks/Block/EditBlockWrapper.jsx index aca72c13ec..0e9e0de374 100644 --- a/packages/volto/src/components/manage/Blocks/Block/EditBlockWrapper.jsx +++ b/packages/volto/src/components/manage/Blocks/Block/EditBlockWrapper.jsx @@ -3,6 +3,7 @@ import { Icon } from '@plone/volto/components'; import { blockHasValue, buildStyleClassNamesFromData, + buildStyleObjectFromData, } from '@plone/volto/helpers'; import dragSVG from '@plone/volto/icons/drag.svg'; import { Button } from 'semantic-ui-react'; @@ -57,7 +58,8 @@ const EditBlockWrapper = (props) => { ? data.required : includes(config.blocks.requiredBlocks, type); - const styles = buildStyleClassNamesFromData(data.styles); + const classNames = buildStyleClassNamesFromData(data.styles); + const style = buildStyleObjectFromData(data.styles); return (
{ // Right now, we can have the alignment information in the styles property or in the // block data root, we inject the classname here for having control over the whole // Block Edit wrapper - className={cx(`block-editor-${data['@type']}`, styles, { + className={cx(`block-editor-${data['@type']}`, classNames, { [data.align]: data.align, })} + style={style} >
{ - let classNames = []; + let classNames, + style = []; const { children, content, data = {}, block } = props; classNames = buildStyleClassNamesFromData(data.styles); @@ -16,11 +18,15 @@ const StyleWrapper = (props) => { data, classNames, }); + + style = buildStyleObjectFromData(data.styles); + const rewrittenChildren = React.Children.map(children, (child) => { if (React.isValidElement(child)) { const childProps = { ...props, className: cx([child.props.className, ...classNames]), + style: { ...child.props.style, ...style }, }; return React.cloneElement(child, childProps); } diff --git a/packages/volto/src/components/manage/Blocks/Grid/View.jsx b/packages/volto/src/components/manage/Blocks/Grid/View.jsx index 504b5ccd78..b97676f36a 100644 --- a/packages/volto/src/components/manage/Blocks/Grid/View.jsx +++ b/packages/volto/src/components/manage/Blocks/Grid/View.jsx @@ -5,7 +5,7 @@ import { withBlockExtensions } from '@plone/volto/helpers'; import config from '@plone/volto/registry'; const GridBlockView = (props) => { - const { data, path, className } = props; + const { data, path, className, style } = props; const metadata = props.metadata || props.properties; const columns = data.blocks_layout.items; const blocksConfig = @@ -22,6 +22,7 @@ const GridBlockView = (props) => { three: columns?.length === 3, four: columns?.length === 4, })} + style={style} > {data.headline &&

{data.headline}

} diff --git a/packages/volto/src/components/manage/Blocks/Image/View.jsx b/packages/volto/src/components/manage/Blocks/Image/View.jsx index cfb694e397..6d0964ca90 100644 --- a/packages/volto/src/components/manage/Blocks/Image/View.jsx +++ b/packages/volto/src/components/manage/Blocks/Image/View.jsx @@ -9,7 +9,7 @@ import { } from '@plone/volto/helpers'; import config from '@plone/volto/registry'; -export const View = ({ className, data, detached, properties }) => { +export const View = ({ className, data, detached, properties, style }) => { const href = data?.href?.[0]?.['@id'] || ''; const Image = config.getComponent({ name: 'Image' }).component; @@ -25,6 +25,7 @@ export const View = ({ className, data, detached, properties }) => { data.align, className, )} + style={style} > {data.url && ( <> diff --git a/packages/volto/src/components/manage/Blocks/Listing/View.jsx b/packages/volto/src/components/manage/Blocks/Listing/View.jsx index b70adfdf0b..dc31dc16ea 100644 --- a/packages/volto/src/components/manage/Blocks/Listing/View.jsx +++ b/packages/volto/src/components/manage/Blocks/Listing/View.jsx @@ -6,11 +6,12 @@ import { withBlockExtensions } from '@plone/volto/helpers'; import { ListingBlockBody as ListingBody } from '@plone/volto/components'; const View = (props) => { - const { data, path, pathname, className } = props; + const { data, path, pathname, className, style } = props; return (
diff --git a/packages/volto/src/components/manage/Blocks/Teaser/DefaultBody.jsx b/packages/volto/src/components/manage/Blocks/Teaser/DefaultBody.jsx index 48862e2c31..e6d87f2236 100644 --- a/packages/volto/src/components/manage/Blocks/Teaser/DefaultBody.jsx +++ b/packages/volto/src/components/manage/Blocks/Teaser/DefaultBody.jsx @@ -18,7 +18,7 @@ const messages = defineMessages({ }); const TeaserDefaultTemplate = (props) => { - const { className, data, isEditMode } = props; + const { className, data, isEditMode, style } = props; const intl = useIntl(); const href = data.href?.[0]; const image = data.preview_image?.[0]; @@ -27,7 +27,7 @@ const TeaserDefaultTemplate = (props) => { const { openExternalLinkInNewTab } = config.settings; return ( -
+
<> {!href && isEditMode && ( diff --git a/packages/volto/src/helpers/Blocks/Blocks.js b/packages/volto/src/helpers/Blocks/Blocks.js index c11552830e..043ab952c5 100644 --- a/packages/volto/src/helpers/Blocks/Blocks.js +++ b/packages/volto/src/helpers/Blocks/Blocks.js @@ -563,7 +563,7 @@ export const styleToClassName = (key, value, prefix = '') => { }; export const buildStyleClassNamesFromData = (obj = {}, prefix = '') => { - // styles has the form: + // style wrapper object has the form: // const styles = { // color: 'red', // backgroundColor: '#AABBCC', @@ -571,6 +571,7 @@ export const buildStyleClassNamesFromData = (obj = {}, prefix = '') => { // Returns: ['has--color--red', 'has--backgroundColor--AABBCC'] return Object.entries(obj) + .filter(([k, v]) => !k.startsWith('--')) .reduce( (acc, [k, v]) => [ ...acc, @@ -602,6 +603,60 @@ export const buildStyleClassNamesExtenders = ({ ); }; +/** + * Converts a name+value style pair (ex: color/red) to a pair of [k, v], + * such as ["color", "red"] so it can be converted back to an object. + * For now, only covering the 'CSSProperty' use case. + */ +export const styleDataToStyleObject = (key, value, prefix = '') => { + if (prefix) { + return [`--${prefix}${key.replace('--', '')}`, value]; + } else { + return [key, value]; + } +}; + +/** + * Generate styles object from data + * + * @function buildStyleObjectFromData + * @param {Object} obj A style wrapper object data + * @param {string} prefix The prefix (could be dragged from a recursive call, initially empty) + * @return {Object} The style object ready to be passed as prop + */ +export const buildStyleObjectFromData = (obj = {}, prefix = '') => { + // style wrapper object has the form: + // const styles = { + // color: 'red', + // '--background-color': '#AABBCC', + // } + // Returns: {'--background-color: '#AABBCC'} + + return Object.fromEntries( + Object.entries(obj) + .filter(([k, v]) => k.startsWith('--') || isObject(v)) + .reduce( + (acc, [k, v]) => [ + ...acc, + // Kept for easy debugging + // ...(() => { + // if (isObject(v)) { + // return Object.entries( + // buildStyleObjectFromData(v, `${prefix}${k}--`), + // ); + // } + // return [styleDataToStyleObject(k, v, prefix)]; + // })(), + ...(isObject(v) + ? Object.entries(buildStyleObjectFromData(v, `${prefix}${k}--`)) + : [styleDataToStyleObject(k, v, prefix)]), + ], + [], + ) + .filter((v) => !!v), + ); +}; + /** * Return previous/next blocks given the content object and the current block id * diff --git a/packages/volto/src/helpers/Blocks/Blocks.test.js b/packages/volto/src/helpers/Blocks/Blocks.test.js index 6dc73728f3..4c53fe3375 100644 --- a/packages/volto/src/helpers/Blocks/Blocks.test.js +++ b/packages/volto/src/helpers/Blocks/Blocks.test.js @@ -18,6 +18,7 @@ import { applySchemaDefaults, buildStyleClassNamesFromData, buildStyleClassNamesExtenders, + buildStyleObjectFromData, getPreviousNextBlock, blocksFormGenerator, findBlocks, @@ -1066,6 +1067,62 @@ describe('Blocks', () => { }; expect(buildStyleClassNamesFromData(styles)).toEqual([]); }); + + it('It does not output any className for style converter values', () => { + const styles = { + color: 'red', + '--background-color': '#FFF', + }; + expect(buildStyleClassNamesFromData(styles)).toEqual(['has--color--red']); + }); + + it.skip('It does not output any className for unknown converter values', () => { + const styles = { + color: 'red', + 'backgroundColor:style': '#FFF', + }; + expect(buildStyleClassNamesFromData(styles)).toEqual(['has--color--red']); + }); + }); + + describe('buildStyleObjectFromData', () => { + it('Understands style converter for style values, no styles found', () => { + const styles = { + color: 'red', + backgroundColor: '#FFF', + }; + expect(buildStyleObjectFromData(styles)).toEqual({}); + }); + + it('Understands style converter for style values', () => { + const styles = { + color: 'red', + '--background-color': '#FFF', + }; + expect(buildStyleObjectFromData(styles)).toEqual({ + '--background-color': '#FFF', + }); + }); + + it('Supports multiple nested levels', () => { + const styles = { + '--color': 'red', + backgroundColor: '#AABBCC', + nested: { + l1: 'white', + '--foo': 'white', + level2: { + '--foo': '#fff', + bar: '#000', + }, + }, + }; + expect(buildStyleObjectFromData(styles)).toEqual({ + '--color': 'red', + '--nested--foo': 'white', + '--nested--level2--foo': '#fff', + }); + }); }); describe('getPreviousNextBlock', () => { diff --git a/packages/volto/src/helpers/index.js b/packages/volto/src/helpers/index.js index 742e24dab7..9e9f7c91da 100644 --- a/packages/volto/src/helpers/index.js +++ b/packages/volto/src/helpers/index.js @@ -58,6 +58,7 @@ export { blocksFormGenerator, buildStyleClassNamesFromData, buildStyleClassNamesExtenders, + buildStyleObjectFromData, getPreviousNextBlock, findBlocks, } from '@plone/volto/helpers/Blocks/Blocks';