diff --git a/packages/block-editor/src/components/block-inspector/index.js b/packages/block-editor/src/components/block-inspector/index.js index 16ee894e8fe182..0c22016e8e842a 100644 --- a/packages/block-editor/src/components/block-inspector/index.js +++ b/packages/block-editor/src/components/block-inspector/index.js @@ -57,6 +57,10 @@ function StyleInspectorSlots( { return ( <> + - + { showPositionControls && } { showBindingsControls && ( @@ -98,6 +103,10 @@ function StyleStateInspectorSlots( { blockName, selectedBlockStyleState } ) { ! hasPseudoBlockStyleState( selectedBlockStyleState ); return ( <> + - { showLayoutControls && ( + ); } diff --git a/packages/block-editor/src/components/colors-gradients/control.js b/packages/block-editor/src/components/colors-gradients/control.js index 5b384e774080e0..3bbe522bb82457 100644 --- a/packages/block-editor/src/components/colors-gradients/control.js +++ b/packages/block-editor/src/components/colors-gradients/control.js @@ -12,6 +12,7 @@ import { __experimentalVStack as VStack, ColorPalette, GradientPicker, + Notice, privateApis as componentsPrivateApis, } from '@wordpress/components'; @@ -48,6 +49,7 @@ function ColorGradientControlInner( { showTitle = true, enableAlpha, headingLevel, + noticeProps, } ) { const canChooseAColor = onColorChange && @@ -67,20 +69,35 @@ function ColorGradientControlInner( { } : ( newColor, _index, newSlug ) => onColorChange( newColor, newSlug ); + const colorPalette = ( + + ); + const tabPanels = { + // The `ColorPalette` must stay at a stable position in the tree whether + // or not a notice is present. Wrapping it in a `VStack` only when a + // notice appears remounts it, which resets the custom color picker back + // to the swatch view mid-edit. Keep `ColorPalette` last and toggle only + // the notice ahead of it; the notice's own bottom margin provides the + // spacing the wrapper used to. [ TAB_IDS.color ]: ( - + <> + { noticeProps && ( + + ) } + { colorPalette } + ), [ TAB_IDS.gradient ]: ( { : true } { ...props } - className="block-editor-tools-panel-color-gradient-settings__item" + className={ clsx( + 'block-editor-color-gradient-item', + 'block-editor-tools-panel-color-gradient-settings__item' + ) } panelId={ panelId } // Pass resetAllFilter if supplied due to rendering via SlotFill // into parent ToolsPanel. diff --git a/packages/block-editor/src/components/colors-gradients/style.scss b/packages/block-editor/src/components/colors-gradients/style.scss index 43f076345ac58b..8e32894c316434 100644 --- a/packages/block-editor/src/components/colors-gradients/style.scss +++ b/packages/block-editor/src/components/colors-gradients/style.scss @@ -68,14 +68,23 @@ $swatch-gap: 12px; background: linear-gradient(-45deg, transparent 48%, $gray-300 48%, $gray-300 52%, transparent 52%); } +// Space the in-popover contrast notice away from the color palette below it. +.block-editor-panel-color-gradient-settings__contrast-notice { + margin-bottom: $grid-unit-20; +} + /** * The following styles replicate the separated border of the * `ItemGroup` component but allows for hidden items. This is because * to maintain the order of `ToolsPanel` controls, each `ToolsPanelItem` * must at least render a placeholder which would otherwise interfere * with the `:last-child` styles. +* +* Applied via the shared `block-editor-color-gradient-item` class on +* color/gradient dropdown items across the Color, Background, and +* Typography panels. */ -.block-editor-tools-panel-color-gradient-settings__item { +.block-editor-color-gradient-item { padding: 0; max-width: 100%; position: relative; @@ -87,7 +96,6 @@ $swatch-gap: 12px; // Identify the first visible instance as placeholder items will not have this class. &:nth-child(1 of &) { - margin-top: $grid-unit-30; border-top-left-radius: $radius-small; border-top-right-radius: $radius-small; border-top: 1px solid $gray-300; @@ -127,6 +135,12 @@ $swatch-gap: 12px; text-overflow: ellipsis; max-width: calc(100% - ($button-size-next-default-40px + $grid-unit-05)); } + + // Reserve extra space for the always-visible contrast warning icon, + // which sits alongside the hover-revealed reset button. + > button.has-contrast-warning .block-editor-panel-color-gradient-settings__color-name { + max-width: calc(100% - ($button-size-next-default-40px + $button-size-small + $grid-unit)); + } } .block-editor-panel-color-gradient-settings__dropdown { @@ -161,4 +175,27 @@ $swatch-gap: 12px; // Show reset button on devices that do not support hover. opacity: 1; } + + // While a contrast warning is in effect the warning icon occupies the + // far-right slot, so the reset button shifts left to sit beside it. + .block-editor-panel-color-gradient-settings__dropdown.has-contrast-warning + & { + right: $button-size-small + $grid-unit-05; + } +} + +// Icon-only "Low contrast" warning shown to the right of the color +// control toggle whenever the selection has insufficient contrast. +// Unlike the reset button it stays visible without hover. It is not a +// menu; activating it opens the color picker popover (where the full +// warning notice lives), so it doubles as a shortcut to the fix. The +// accessible name and hover/focus tooltip come from the button label. +.block-editor-panel-color-gradient-settings__contrast-warning { + position: absolute; + right: 0; + top: $grid-unit; + margin: auto $grid-unit auto; + + &.block-editor-panel-color-gradient-settings__contrast-warning { + border-radius: $radius-small; + } } diff --git a/packages/block-editor/src/components/contrast-checker/README.md b/packages/block-editor/src/components/contrast-checker/README.md index cb9d18252a04c9..11cc222815801d 100644 --- a/packages/block-editor/src/components/contrast-checker/README.md +++ b/packages/block-editor/src/components/contrast-checker/README.md @@ -69,3 +69,12 @@ The text color to check the contrast of the background against. - Type: `String` - Required: No + +#### messageOverride + +Custom warning message to display (and announce) instead of the default +contrast guidance when the color combination has insufficient contrast. Useful +for providing panel-specific, more concise copy. + +- Type: `String` +- Required: No diff --git a/packages/block-editor/src/components/contrast-checker/index.js b/packages/block-editor/src/components/contrast-checker/index.js index 98b4a6fb1afdf6..73e77923be7dec 100644 --- a/packages/block-editor/src/components/contrast-checker/index.js +++ b/packages/block-editor/src/components/contrast-checker/index.js @@ -14,7 +14,28 @@ import { speak } from '@wordpress/a11y'; extend( [ namesPlugin, a11yPlugin ] ); -function ContrastChecker( { +/** + * Computes a contrast warning for the given color combination, if any. + * + * Shared between the `ContrastChecker` component and the block inspector + * contrast warning indicators, which surface the result without rendering + * a notice. + * + * @param {Object} props + * @param {string} [props.backgroundColor] Background color. + * @param {string} [props.fallbackBackgroundColor] Fallback background color. + * @param {string} [props.fallbackTextColor] Fallback text color. + * @param {string} [props.fallbackLinkColor] Fallback link color. + * @param {number} [props.fontSize] Font size value in pixels. + * @param {boolean} [props.isLargeText] Whether the text is large. + * @param {string} [props.textColor] Text color. + * @param {string} [props.linkColor] Link color. + * @param {string} [props.messageOverride] Caller-provided copy used in place of the generic guidance. + * @param {boolean} [props.enableAlphaChecker] Whether to warn about transparent text. + * + * @return {?Object} `{ message, speakMessage }` when contrast is insufficient, otherwise `null`. + */ +export function getContrastWarning( { backgroundColor, fallbackBackgroundColor, fallbackTextColor, @@ -23,6 +44,7 @@ function ContrastChecker( { isLargeText, textColor, linkColor, + messageOverride, enableAlphaChecker = false, } ) { const currentBackgroundColor = backgroundColor || fallbackBackgroundColor; @@ -81,6 +103,13 @@ function ContrastChecker( { if ( backgroundColorHasTransparency || textHasTransparency ) { continue; } + // A caller can provide panel-specific copy that is clearer and + // more concise than the generic brighter/darker guidance. + if ( messageOverride ) { + message = messageOverride; + speakMessage = messageOverride; + break; + } message = backgroundColorBrightness < colordTextColor.brightness() ? sprintf( @@ -119,11 +148,21 @@ function ContrastChecker( { return null; } + return { message, speakMessage }; +} + +function ContrastChecker( props ) { + const warning = getContrastWarning( props ); + + if ( ! warning ) { + return null; + } + // Note: The `Notice` component can speak messages via its `spokenMessage` // prop, but the contrast checker requires granular control over when the // announcements are made. Notably, the message will be re-announced if a // new color combination is selected and the contrast is still insufficient. - speak( speakMessage ); + speak( warning.speakMessage ); return (
@@ -132,7 +171,7 @@ function ContrastChecker( { status="warning" isDismissible={ false } > - { message } + { warning.message }
); diff --git a/packages/block-editor/src/components/global-styles/background-panel.js b/packages/block-editor/src/components/global-styles/background-panel.js index 8a151f8a376537..13d6ff11285c6f 100644 --- a/packages/block-editor/src/components/global-styles/background-panel.js +++ b/packages/block-editor/src/components/global-styles/background-panel.js @@ -7,19 +7,24 @@ import { } from '@wordpress/components'; import { useCallback } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; -import { getValueFromVariable } from '@wordpress/global-styles-engine'; /** * Internal dependencies */ import BackgroundImageControl from '../background-image-control'; -import { ColorPanelDropdown } from './color-panel'; -import { useGradientsPerOrigin } from './hooks'; +import ColorGradientDropdownItem from './color-gradient-dropdown-item'; +import { useHasBackgroundColorPanel } from './color-panel'; +import { useColorGradientSettings } from './hooks'; import { useToolsPanelDropdownMenuProps } from './utils'; import { setImmutably } from '../../utils/object'; +import { + extractPresetSlug, + encodeColorValueWithPalette, +} from '../../utils/color-values'; const DEFAULT_CONTROLS = { backgroundImage: true, + backgroundColor: true, gradient: true, }; @@ -39,12 +44,17 @@ export function useHasBackgroundControl( settings, feature ) { * `settings.background.backgroundSize` exists also, * but can only be used if settings?.background?.backgroundImage is `true`. * + * The panel is also shown when the block has color panel background + * support (`settings.color.background`), because background color and + * the legacy `color.gradient` control are rendered here. + * * @param {Object} settings Site settings * @return {boolean} Whether site settings has activated background panel. */ export function useHasBackgroundPanel( settings ) { + const hasBackgroundColor = useHasBackgroundColorPanel( settings ); const { backgroundImage, gradient } = settings?.background || {}; - return backgroundImage || gradient; + return backgroundImage || gradient || hasBackgroundColor; } /** @@ -92,7 +102,30 @@ export function hasBackgroundGradientValue( style ) { ); } -function BackgroundToolsPanel( { +/** + * Checks if there is a current value for the background color (written to + * `color.background`). + * + * @param {Object} style Style attribute. + * @return {boolean} Whether the block has a background color value set. + */ +export function hasBackgroundColorValue( style ) { + return !! style?.color?.background; +} + +/** + * Checks if there is a current value in the legacy `color.gradient` location + * (used by blocks with color panel gradient support that haven't adopted the + * `background.gradient` block support). + * + * @param {Object} style Style attribute. + * @return {boolean} Whether the block has a legacy color gradient value set. + */ +export function hasLegacyColorGradientValue( style ) { + return !! style?.color?.gradient; +} + +export function BackgroundToolsPanel( { resetAllFilter, onChange, value, @@ -128,23 +161,46 @@ export default function BackgroundImagePanel( { as: Wrapper = BackgroundToolsPanel, value, onChange, - inheritedValue, + inheritedValue = value, settings, panelId, defaultControls = DEFAULT_CONTROLS, defaultValues = {}, headerLabel = __( 'Background' ), + contrastWarning, } ) { - const gradients = useGradientsPerOrigin( settings ); - const areCustomGradientsEnabled = settings?.color?.customGradient; - const hasGradientColors = gradients.length > 0 || areCustomGradientsEnabled; + const { + colors, + gradients, + allColors, + areCustomSolidsEnabled, + areCustomGradientsEnabled, + hasSolidColors, + hasGradientColors, + decodeValue, + encodeGradientValue, + } = useColorGradientSettings( settings ); const hasBackgroundGradientControl = useHasBackgroundControl( settings, 'gradient' ); + const hasColorPanelBackgroundSupport = + useHasBackgroundColorPanel( settings ); + const showBackgroundColorControl = + hasColorPanelBackgroundSupport && hasSolidColors; + // New `background.gradient` block support — gradient lives under the + // `background` style path. const showBackgroundGradientControl = hasGradientColors && hasBackgroundGradientControl; + // Legacy `color.gradient` path — only rendered when the block has + // color panel background support and hasn't adopted the newer + // `background.gradient` support. Keeps the UI consistent for blocks + // that still write gradients to `color.gradient`. + const showLegacyColorGradientControl = + hasColorPanelBackgroundSupport && + hasGradientColors && + ! hasBackgroundGradientControl; const showBackgroundImageControl = useHasBackgroundControl( settings, 'backgroundImage' @@ -152,38 +208,38 @@ export default function BackgroundImagePanel( { const resetAllFilter = useCallback( ( previousValue ) => { + const clearsColorBackground = showBackgroundColorControl; + const clearsColorGradient = + hasBackgroundGradientControl || showLegacyColorGradientControl; + if ( ! clearsColorBackground && ! clearsColorGradient ) { + return { ...previousValue, background: {} }; + } return { ...previousValue, background: {}, - color: hasBackgroundGradientControl - ? { - ...previousValue?.color, - gradient: undefined, - } - : previousValue?.color, + color: { + ...previousValue?.color, + ...( clearsColorBackground && { background: undefined } ), + ...( clearsColorGradient && { gradient: undefined } ), + }, }; }, - [ hasBackgroundGradientControl ] + [ + hasBackgroundGradientControl, + showBackgroundColorControl, + showLegacyColorGradientControl, + ] ); - if ( ! showBackgroundGradientControl && ! showBackgroundImageControl ) { + if ( + ! showBackgroundImageControl && + ! showBackgroundColorControl && + ! showBackgroundGradientControl && + ! showLegacyColorGradientControl + ) { return null; } - const decodeValue = ( rawValue ) => - getValueFromVariable( { settings }, '', rawValue ); - const encodeGradientValue = ( gradientValue ) => { - const allGradients = gradients.flatMap( - ( { gradients: originGradients } ) => originGradients - ); - const gradientObject = allGradients.find( - ( { gradient } ) => gradient === gradientValue - ); - return gradientObject - ? 'var:preset|gradient|' + gradientObject.slug - : gradientValue; - }; - const resetBackground = () => onChange( setImmutably( @@ -203,6 +259,50 @@ export default function BackgroundImagePanel( { onChange( newValue ); }; + // Background color (written to `color.background`). + const backgroundColor = decodeValue( inheritedValue?.color?.background ); + const userBackgroundColor = decodeValue( value?.color?.background ); + const setBackgroundColor = ( newColor, newSlug ) => { + const newValue = setImmutably( + value, + [ 'color', 'background' ], + encodeColorValueWithPalette( allColors, newColor, newSlug ) + ); + // Legacy `color.gradient` is mutually exclusive with + // `color.background`. `background.gradient` is independent and + // should not be touched. + if ( showLegacyColorGradientControl ) { + newValue.color.gradient = undefined; + } + onChange( newValue ); + }; + const resetBackgroundColor = () => { + const newValue = setImmutably( + value, + [ 'color', 'background' ], + undefined + ); + if ( showLegacyColorGradientControl ) { + newValue.color.gradient = undefined; + } + onChange( newValue ); + }; + + // Legacy `color.gradient` setters. + const legacyColorGradient = decodeValue( inheritedValue?.color?.gradient ); + const userLegacyColorGradient = decodeValue( value?.color?.gradient ); + const setLegacyColorGradient = ( newGradient ) => { + const newValue = setImmutably( + value, + [ 'color', 'gradient' ], + encodeGradientValue( newGradient ) + ); + newValue.color.background = undefined; + onChange( newValue ); + }; + const resetLegacyColorGradient = () => + onChange( setImmutably( value, [ 'color', 'gradient' ], undefined ) ); + // Get current gradient value, decoding preset slug references. // Fall back to color.gradient for legacy blocks that haven't migrated // to background.gradient yet (mirrors block inspector fallback in @@ -237,7 +337,7 @@ export default function BackgroundImagePanel( { > { showBackgroundImageControl && ( hasBackgroundImageValue( value ) } label={ __( 'Image' ) } onDeselect={ resetBackground } @@ -254,9 +354,49 @@ export default function BackgroundImagePanel( { /> ) } + { showBackgroundColorControl && ( + hasBackgroundColorValue( value ) } + resetValue={ resetBackgroundColor } + isShownByDefault={ defaultControls.backgroundColor } + indicators={ [ userBackgroundColor ?? backgroundColor ] } + contrastWarning={ contrastWarning } + tabs={ [ + { + key: 'background', + label: __( 'Color' ), + inheritedValue: + userBackgroundColor ?? backgroundColor, + // Resolve the slug from the same source as the + // displayed value (user value first, then the + // inherited fallback). For a block instance the + // selection lives in `value` while `inheritedValue` + // only holds the global styles fallback, so reading + // the slug from `inheritedValue` alone would miss it + // and two same-hex presets would both appear selected. + inheritedSlug: + extractPresetSlug( + value?.color?.background, + 'color' + ) ?? + extractPresetSlug( + inheritedValue?.color?.background, + 'color' + ), + setValue: setBackgroundColor, + userValue: userBackgroundColor, + }, + ] } + colorGradientControlSettings={ { + colors, + disableCustomColors: ! areCustomSolidsEnabled, + } } + panelId={ panelId } + /> + ) } { showBackgroundGradientControl && ( - hasBackgroundGradientValue( value ) } resetValue={ resetGradient } @@ -280,6 +420,33 @@ export default function BackgroundImagePanel( { panelId={ panelId } /> ) } + { showLegacyColorGradientControl && ( + hasLegacyColorGradientValue( value ) } + resetValue={ resetLegacyColorGradient } + isShownByDefault={ defaultControls.gradient } + indicators={ [ + userLegacyColorGradient ?? legacyColorGradient, + ] } + tabs={ [ + { + key: 'gradient', + label: __( 'Gradient' ), + inheritedValue: + userLegacyColorGradient ?? legacyColorGradient, + setValue: setLegacyColorGradient, + userValue: userLegacyColorGradient, + isGradient: true, + }, + ] } + colorGradientControlSettings={ { + gradients, + disableCustomGradients: ! areCustomGradientsEnabled, + } } + panelId={ panelId } + /> + ) } ); } diff --git a/packages/block-editor/src/components/global-styles/color-gradient-dropdown-item.js b/packages/block-editor/src/components/global-styles/color-gradient-dropdown-item.js new file mode 100644 index 00000000000000..ee6b2c7298e5b8 --- /dev/null +++ b/packages/block-editor/src/components/global-styles/color-gradient-dropdown-item.js @@ -0,0 +1,258 @@ +/** + * External dependencies + */ +import clsx from 'clsx'; + +/** + * WordPress dependencies + */ +import { + __experimentalToolsPanelItem as ToolsPanelItem, + __experimentalHStack as HStack, + __experimentalZStack as ZStack, + __experimentalDropdownContentWrapper as DropdownContentWrapper, + ColorIndicator, + Flex, + FlexItem, + Dropdown, + Button, + privateApis as componentsPrivateApis, +} from '@wordpress/components'; +import { useRef } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { reset as resetIcon, caution as cautionIcon } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import ColorGradientControl from '../colors-gradients/control'; +import { unlock } from '../../lock-unlock'; + +const { Tabs } = unlock( componentsPrivateApis ); + +/** + * @typedef {Object} DropdownContentProps + * @property {Array} tabs Tab configurations to render. + * @property {Object} colorGradientControlSettings Settings passed to ColorGradientControl. + * @property {string} [contrastWarning] Contrast warning message for the color value. + */ + +/** + * Renders the dropdown content containing the color/gradient picker tabs. + * + * @param {DropdownContentProps} props + */ +function DropdownContent( { + tabs, + colorGradientControlSettings, + contrastWarning, +} ) { + const { key: firstTabKey, ...firstTab } = tabs[ 0 ] ?? {}; + const defaultTabId = tabs.find( + ( tab ) => tab.userValue !== undefined + )?.key; + return ( + +
+ { tabs.length === 1 && ( + + ) } + { tabs.length > 1 && ( + + + { tabs.map( ( tab ) => ( + + { tab.label } + + ) ) } + + + { tabs.map( ( tab ) => { + const { key: tabKey, ...restTabProps } = tab; + return ( + + + + ); + } ) } + + ) } +
+
+ ); +} + +const popoverProps = { + placement: 'left-start', + offset: 36, + shift: true, +}; + +const LabeledColorIndicators = ( { indicators, label } ) => ( + + + { indicators.map( ( indicator, index ) => ( + + + + ) ) } + + + { label } + + +); + +function ColorGradientTab( { + isGradient, + inheritedValue, + inheritedSlug, + userValue, + setValue, + colorGradientControlSettings, + contrastWarning, +} ) { + return ( + + ); +} + +// Renders a ToolsPanelItem that opens a dropdown containing one or more +// color/gradient pickers. Shared between the Color, Background, and +// Typography panels for consistent color-style controls. +export default function ColorGradientDropdownItem( { + label, + hasValue, + resetValue, + isShownByDefault, + indicators, + tabs, + colorGradientControlSettings, + panelId, + contrastWarning, + className = 'block-editor-tools-panel-color-gradient-settings__item', +} ) { + const colorGradientDropdownButtonRef = useRef( undefined ); + return ( + + { + const toggleProps = { + onClick: onToggle, + className: clsx( + 'block-editor-panel-color-gradient-settings__dropdown', + { + 'is-open': isOpen, + 'has-contrast-warning': !! contrastWarning, + } + ), + 'aria-expanded': isOpen, + ref: colorGradientDropdownButtonRef, + }; + + return ( + <> + + { hasValue() && ( + - { hasValue() && ( -