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

CSS Class Names: Add dropdown to choose from block styles #34521

Closed
wants to merge 10 commits into from
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/**
* External dependencies
*/
import classnames from 'classnames';

/**
* WordPress dependencies
*/
import {
DropdownMenu,
MenuGroup,
MenuItem,
TextControl,
} from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { store as blocksStore } from '@wordpress/blocks';
import { useDispatch, useSelect } from '@wordpress/data';
import { check, moreVertical } from '@wordpress/icons';

/**
* Internal dependencies
*/
import { InspectorControls } from '../';
import { getActiveStyle, replaceActiveStyle } from '../block-styles/utils';
import { store as blockEditorStore } from '../../store';

/**
* @typedef {Object} CustomClassNameMenuDropDownMenuProps
* @property {string} activeStyle The currently active style.
* @property {Object} blockStyles A collection of Block Styles.
* @property {Function} onSelectStyleClassName An onClick handler.
*/

/**
* Returns a DropDownMenu component.
*
* @param {CustomClassNameMenuDropDownMenuProps} props The component.
* @return {WPComponent} The menu item component.
*/
function CustomClassNameMenuDropDownMenu( {
activeStyle,
blockStyles,
onSelectStyleClassName,
} ) {
return (
<DropdownMenu
className="additional-class-name-control__block-style-dropdown"
icon={ moreVertical }
label={ __( 'Existing Styles' ) }
>
{ ( { onClose } ) => (
<MenuGroup label={ __( 'Block style classes' ) }>
{ blockStyles.map( ( style ) => {
const isSelected = activeStyle?.name === style.name;
const icon = isSelected ? check : null;
return (
<MenuItem
key={ style?.label }
icon={ icon }
isSelected={ isSelected }
onClick={ () => {
onSelectStyleClassName( style );
onClose();
} }
role="menuitemcheckbox"
>
{ style?.label }
</MenuItem>
);
} ) }
</MenuGroup>
) }
</DropdownMenu>
);
}

/**
* @typedef {Object} CustomClassNameControlProps
* @property {string} clientId Selected Block clientId.
* @property {string} name Selected Block name.
* @property {Object} attributes Selected Block's attributes.
* @property {Function} setAttributes Set attributes callback.
*/

/**
* Control to display custom class name control dropdown and text input.
*
* @param {CustomClassNameControlProps} props Component props.
*
* @return {WPElement} Font appearance control.
*/
export default function CustomClassNameControl( {
clientId,
name,
attributes,
setAttributes,
} ) {
const { updateBlockAttributes } = useDispatch( blockEditorStore );

const blockStyles = useSelect(
( select ) => select( blocksStore ).getBlockStyles( name ),
[ name, attributes.className ]
);

const hasBlockStyles = blockStyles && !! blockStyles.length;

const activeStyle = hasBlockStyles
? getActiveStyle( blockStyles, attributes.className || '' )
: null;

const onSelectStyleClassName = ( style ) => {
const styleClassName = replaceActiveStyle(
attributes.className,
activeStyle,
style
);
updateBlockAttributes( clientId, {
className: styleClassName,
} );
};

const additionalClassNameContainerClasses = classnames(
'additional-class-name-control__container',
{
'has-block-styles': hasBlockStyles,
}
);

return (
<InspectorControls __experimentalGroup="advanced">
<div className={ additionalClassNameContainerClasses }>
<TextControl
className="additional-class-name-control__text-control"
autoComplete="off"
label={ __( 'Additional CSS class(es)' ) }
value={ attributes.className || '' }
onChange={ ( nextValue ) => {
setAttributes( {
className: nextValue !== '' ? nextValue : undefined,
} );
} }
help={ __( 'Separate multiple classes with spaces.' ) }
>
{ hasBlockStyles && (
<CustomClassNameMenuDropDownMenu
activeStyle={ activeStyle }
blockStyles={ blockStyles }
onSelectStyleClassName={ onSelectStyleClassName }
/>
) }
</TextControl>
</div>
</InspectorControls>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
.additional-class-name-control__container.has-block-styles {
.additional-class-name-control__text-control > .components-base-control__field {
position: relative;
}
}

.additional-class-name-control__block-style-dropdown {
position: absolute;
top: auto;
right: 2px;
bottom: 2px;
background: $white;

button.components-button.has-icon {
min-width: 24px;
height: 24px;
padding: 0;

svg {
height: 27px;
width: 27px;
@include break-medium() {
height: 18px;
width: 18px;
}
}
}
}
24 changes: 3 additions & 21 deletions packages/block-editor/src/hooks/custom-class-name.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,13 @@ import classnames from 'classnames';
* WordPress dependencies
*/
import { addFilter } from '@wordpress/hooks';
import { TextControl } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { hasBlockSupport } from '@wordpress/blocks';
import { createHigherOrderComponent } from '@wordpress/compose';

/**
* Internal dependencies
*/
import { InspectorControls } from '../components';
import CustomClassNameControl from '../components/custom-class-name-control';

/**
* Filters registered block settings, extending attributes with anchor using ID
Expand Down Expand Up @@ -55,28 +53,12 @@ export const withInspectorControl = createHigherOrderComponent(
'customClassName',
true
);

if ( hasCustomClassName && props.isSelected ) {
return (
<>
<BlockEdit { ...props } />
<InspectorControls __experimentalGroup="advanced">
<TextControl
autoComplete="off"
label={ __( 'Additional CSS class(es)' ) }
value={ props.attributes.className || '' }
onChange={ ( nextValue ) => {
props.setAttributes( {
className:
nextValue !== ''
? nextValue
: undefined,
} );
} }
help={ __(
'Separate multiple classes with spaces.'
) }
/>
</InspectorControls>
<CustomClassNameControl { ...props } />
</>
);
}
Expand Down
1 change: 1 addition & 0 deletions packages/block-editor/src/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
@import "./components/button-block-appender/style.scss";
@import "./components/colors-gradients/style.scss";
@import "./components/contrast-checker/style.scss";
@import "./components/custom-class-name-control/style.scss";
@import "./components/default-block-appender/style.scss";
@import "./components/duotone-control/style.scss";
@import "./components/font-appearance-control/style.scss";
Expand Down
7 changes: 7 additions & 0 deletions packages/components/src/text-control/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,13 @@ A function that receives the value of the input.
- Type: `function`
- Required: Yes

#### children

The content to be displayed within the `BaseControl`, and as a sibling of `input`.

- Type: `Element`
- Required: No

## Related components

- To offer users more constrained options for input, use SelectControl, RadioControl, CheckboxControl, or RangeControl.
3 changes: 3 additions & 0 deletions packages/components/src/text-control/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import BaseControl from '../base-control';
* @property {string} [className] Classname passed to BaseControl wrapper
* @property {(value: string) => void} onChange Handle changes.
* @property {string} [type='text'] Type of the input.
* @property {WPElement[]} children Children.
*/

/** @typedef {OwnProps & import('react').ComponentProps<'input'>} Props */
Expand All @@ -36,6 +37,7 @@ function TextControl(
className,
onChange,
type = 'text',
children,
...props
},
ref
Expand Down Expand Up @@ -65,6 +67,7 @@ function TextControl(
ref={ ref }
{ ...props }
/>
{ children }
Copy link
Member Author

@ramonjd ramonjd Dec 7, 2021

Choose a reason for hiding this comment

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

By way of explanation, rendering children here was a trade off.

Following the design requirements, we need to be able to add a dropdown menu as a sibling of the text control input.

We could have created a custom component using the constituent parts of <TextControl />. The Advanced panel, however, uses <TextControl /> multiple times, which means that updates would have to be copied over to the custom component to retain consistency.

Does adding children here contravene any component guidelines from a technical or philosophical standpoint?

As far as I can tell, there are no side effects to adding and using the children prop, though I am worried that it might be "impure" in that it invites manifestations of a base component.

I'm overthinking things, yes.

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'd be grateful for your thoughts here @ciampo whenever you get time. 🙏

Copy link
Contributor

Choose a reason for hiding this comment

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

Thank you for the ping! Always happy to advise / give direction on components :)

I would say that rendering children inside a TextControl goes a bit against the way the component is supposed to work. TextControl, in my mind, is supposed to be an enriched input field.

I would personally recommend that, instead of adding children to TextInput, you render the dropdown menu as a sibling of TextInput in the CustomClassNameControl component.

Also, cc'ing @diegohaz and @mirka in case they wanted to add their views here as well

Copy link
Member Author

Choose a reason for hiding this comment

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

Thanks for the feedback and for confirming what the tiny voice in my brain was trying to tell me. I was truly struggling with the conflict 😆

I would personally recommend that, instead of adding children to TextInput, you render the dropdown menu as a sibling of TextInput in the CustomClassNameControl component.

I'll try that out, cheers! If the other Advanced control inputs end up looking or working differently I can try to switch them all over to TextInput as well.

Copy link
Member Author

Choose a reason for hiding this comment

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

Ah, I just realized that TextInput isn't a component yet. Or at least we haven't migrated it from g2.

I should know, I started it and never finished! 😅

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah, I just realized that TextInput isn't a component yet. Or at least we haven't migrated it from g2.

We did initially look into it as well after you started it, but we later stopped because it was a very complicated task, since TextInput is very complex (it tries to handle single/multi line text and number input) and represents a very different approach to what we currently have in Gutenberg.

Copy link
Member Author

Choose a reason for hiding this comment

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

We did initially look into it as well after you started it, but we later stopped because it was a very complicated task

Thanks for the background info. 🙇

I discovered personally how tricky it was 😬 so I feel better that folks with a lot more experience than I have also felt the same!

</BaseControl>
);
}
Expand Down
43 changes: 36 additions & 7 deletions packages/components/src/text-control/stories/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useState } from '@wordpress/element';
* Internal dependencies
*/
import TextControl from '../';
import Button from '../../button';

export default {
title: 'Components/TextControl',
Expand All @@ -22,18 +23,34 @@ export default {
};

const TextControlWithState = ( props ) => {
const [ value, setValue ] = useState();
const [ value, setValue ] = useState( '' );

return <TextControl { ...props } value={ value } onChange={ setValue } />;
};

export const _default = () => {
const label = text( 'Label', 'Label Text' );
const hideLabelFromVision = boolean( 'Hide Label From Vision', false );
const help = text( 'Help Text', 'Help text to explain the input.' );
const type = text( 'Input Type', 'text' );
const className = text( 'Class Name', '' );
const TextControlWithStateAndChildren = ( props ) => {
const [ value, setValue ] = useState( '' );

return (
<TextControl { ...props } value={ value } onChange={ setValue }>
<Button
disabled={ ! value }
variant="primary"
onClick={ () => setValue( '' ) }
>
Clear input
</Button>
</TextControl>
);
};

const label = text( 'Label', 'Label Text' );
const hideLabelFromVision = boolean( 'Hide Label From Vision', false );
const help = text( 'Help Text', 'Help text to explain the input.' );
const type = text( 'Input Type', 'text' );
const className = text( 'Class Name', '' );

export const _default = () => {
return (
<TextControlWithState
label={ label }
Expand All @@ -44,3 +61,15 @@ export const _default = () => {
/>
);
};

export const withChildren = () => {
return (
<TextControlWithStateAndChildren
label={ label }
hideLabelFromVision={ hideLabelFromVision }
help={ help }
type={ type }
className={ className }
/>
);
};