Skip to content

Commit

Permalink
Components: Add themeable background color (#45466)
Browse files Browse the repository at this point in the history
* WIP

* Tweak theme presets

* Check default accent color

* Fix math

* Refactor

* Fix props passing

* Simplify background switcher

* Allow undefined values in validation

* Add unit tests

* Add description

* Test more shade generation

* Check for background colors with no good foreground colors

* Test using color variables

* Add story for seeing generated colors

* Fix typo

* Memoize

* Fixup

* Add more contrast validation

* Show issues in stories

* Add tests

* Update readme

* Update TODO comments

* Fixup

* Fixup test

* Add changelog

* Disable toolbar addon for Theme stories
  • Loading branch information
mirka authored Nov 30, 2022
1 parent 4532e82 commit b82058c
Show file tree
Hide file tree
Showing 15 changed files with 649 additions and 92 deletions.
1 change: 1 addition & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
### Experimental

- `ToggleGroupControl`: Only show enclosing border when `isBlock` and not `isDeselectable` ([#45492](https://github.com/WordPress/gutenberg/pull/45492)).
- `Theme`: Add support for custom `background` color ([#45466](https://github.com/WordPress/gutenberg/pull/45466)).

## 22.0.0 (2022-11-02)

Expand Down
30 changes: 30 additions & 0 deletions packages/components/src/button/stories/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,36 @@ Default.args = {
children: 'Code is poetry',
};

export const Primary = Template.bind( {} );
Primary.args = {
...Default.args,
variant: 'primary',
};

export const Secondary = Template.bind( {} );
Secondary.args = {
...Default.args,
variant: 'secondary',
};

export const Tertiary = Template.bind( {} );
Tertiary.args = {
...Default.args,
variant: 'tertiary',
};

export const Link = Template.bind( {} );
Link.args = {
...Default.args,
variant: 'link',
};

export const IsDestructive = Template.bind( {} );
IsDestructive.args = {
...Default.args,
isDestructive: true,
};

export const Icon = Template.bind( {} );
Icon.args = {
label: 'Code is poetry',
Expand Down
31 changes: 17 additions & 14 deletions packages/components/src/button/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
box-sizing: border-box;
padding: 6px 12px;
border-radius: $radius-block-ui;
color: $gray-900;
color: $components-color-foreground;

&[aria-expanded="true"],
&:hover {
Expand Down Expand Up @@ -44,7 +44,7 @@
&.is-primary {
white-space: nowrap;
background: $components-color-accent;
color: $white;
color: $components-color-accent-inverted;
text-decoration: none;
text-shadow: none;

Expand All @@ -53,24 +53,25 @@

&:hover:not(:disabled) {
background: $components-color-accent-darker-10;
color: $white;
color: $components-color-accent-inverted;
}

&:active:not(:disabled) {
background: $components-color-accent-darker-20;
border-color: $components-color-accent-darker-20;
color: $white;
color: $components-color-accent-inverted;
}

&:focus:not(:disabled) {
box-shadow: inset 0 0 0 1px $white, 0 0 0 var(--wp-admin-border-width-focus) $components-color-accent;
box-shadow: inset 0 0 0 1px $components-color-background, 0 0 0 var(--wp-admin-border-width-focus) $components-color-accent;
}

&:disabled,
&:disabled:active:enabled,
&[aria-disabled="true"],
&[aria-disabled="true"]:enabled, // This catches a situation where a Button is aria-disabled, but not disabled.
&[aria-disabled="true"]:active:enabled {
// TODO: Prepare for theming (https://github.com/WordPress/gutenberg/pull/45466/files#r1030872724)
color: rgba($white, 0.4);
background: $components-color-accent;
border-color: $components-color-accent;
Expand All @@ -79,15 +80,15 @@

&:focus:enabled {
box-shadow:
0 0 0 $border-width $white,
0 0 0 $border-width $components-color-background,
0 0 0 3px $components-color-accent;
}
}

&.is-busy,
&.is-busy:disabled,
&.is-busy[aria-disabled="true"] {
color: $white;
color: $components-color-accent-inverted;
background-size: 100px 100%;
// Disable reason: This function call looks nicer when each argument is on its own line.
/* stylelint-disable */
Expand All @@ -113,7 +114,7 @@
outline: 1px solid transparent;

&:active:not(:disabled) {
background: $gray-300;
background: $components-color-gray-300;
color: $components-color-accent-darker-10;
box-shadow: none;
}
Expand All @@ -126,6 +127,7 @@
&:disabled,
&[aria-disabled="true"],
&[aria-disabled="true"]:hover {
// TODO: Prepare for theming (https://github.com/WordPress/gutenberg/pull/45466/files#r1030872724)
color: lighten($gray-700, 5%);
background: lighten($gray-300, 5%);
transform: none;
Expand Down Expand Up @@ -222,7 +224,7 @@
}

&:not([aria-disabled="true"]):active {
color: inherit;
color: $components-color-foreground;
}

&:disabled,
Expand All @@ -242,6 +244,7 @@
/* stylelint-disable */
background-image: linear-gradient(
-45deg,
// TODO: Prepare for theming (https://github.com/WordPress/gutenberg/pull/45466/files#r1030872724)
darken($white, 2%) 33%,
darken($white, 12%) 33%,
darken($white, 12%) 70%,
Expand Down Expand Up @@ -292,19 +295,19 @@

// Toggled style.
&.is-pressed {
color: $white;
background: $gray-900;
color: $components-color-foreground-inverted;
background: $components-color-foreground;

&:focus:not(:disabled) {
box-shadow: inset 0 0 0 1px $white, 0 0 0 var(--wp-admin-border-width-focus) $components-color-accent;
box-shadow: inset 0 0 0 1px $components-color-background, 0 0 0 var(--wp-admin-border-width-focus) $components-color-accent;

// Windows High Contrast mode will show this outline, but not the box-shadow.
outline: 2px solid transparent;
}

&:hover:not(:disabled) {
color: $white;
background: $gray-900;
color: $components-color-foreground-inverted;
background: $components-color-foreground;
}
}

Expand Down
34 changes: 32 additions & 2 deletions packages/components/src/theme/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const Example = () => {
return (
<Theme accent="red">
<Button variant="primary">I'm red</Button>
<Theme accent="blue">
<Theme accent="blue" background="black">
<Button variant="primary">I'm blue</Button>
</Theme>
</Theme>
Expand All @@ -29,6 +29,36 @@ const Example = () => {

### `accent`: `string`

Used to set the accent color (used by components as the primary color). If an accent color is not defined, the default fallback value is the original WP Admin main theme color. No all valid CSS color syntaxes are supported — in particular, keywords (like `'currentcolor'`, `'inherit'`, `'initial'`, `'revert'`, `'unset'`...) and CSS custom properties (e.g. `var(--my-custom-property)`) are _not_ supported values for this property.
The accent color (used by components as the primary color). If an accent color is not defined, the default fallback value is the original WP Admin main theme color.

Not all valid CSS color syntaxes are supported — in particular, keywords (like `'currentcolor'`, `'inherit'`, `'initial'`, `'revert'`, `'unset'`...) and CSS custom properties (e.g. `var(--my-custom-property)`) are _not_ supported values for this property.

- Required: No

### `background`: `string`

The background color. If a component explicitly has a background, it will be this color. Otherwise, this color will simply be used to determine what the foreground colors should be. The actual background color will need to be set on the component's container element. If a background color is not defined, the default fallback value is #fff.

Not all valid CSS color syntaxes are supported — in particular, keywords (like `'currentcolor'`, `'inherit'`, `'initial'`, `'revert'`, `'unset'`...) and CSS custom properties (e.g. `var(--my-custom-property)`) are _not_ supported values for this property.

- Required: No

## Writing themeable components

If you would like your custom component to be themeable as a child of the `Theme` component, it should use these color variables. (This is a work in progress, and this list of variables may change. We do not recommend using these variables in production at this time.)

- `--wp-components-color-accent`: The accent color.
- `--wp-components-color-accent-darker-10`: A slightly darker version of the accent color.
- `--wp-components-color-accent-darker-20`: An even darker version of the accent color.
- `--wp-components-color-accent-inverted`: The foreground color when the accent color is the background, for example when placing text on the accent color.
- `--wp-components-color-background`: The background color.
- `--wp-components-color-foreground`: The foreground color, for example text.
- `--wp-components-color-foreground-inverted`: The foreground color when the foreground color is the background, for example when placing text on the foreground color.
- Grayscale:
- `--wp-components-color-gray-100`: Used for light gray backgrounds.
- `--wp-components-color-gray-200`: Used sparingly for light borders.
- `--wp-components-color-gray-300`: Used for most borders.
- `--wp-components-color-gray-400`
- `--wp-components-color-gray-600`: Meets 3:1 UI or large text contrast against white.
- `--wp-components-color-gray-700`: Meets 4.6:1 text contrast against white.
- `--wp-components-color-gray-800`
138 changes: 138 additions & 0 deletions packages/components/src/theme/color-algorithms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/**
* External dependencies
*/
import { colord, extend } from 'colord';
import a11yPlugin from 'colord/plugins/a11y';
import namesPlugin from 'colord/plugins/names';

/**
* WordPress dependencies
*/
import warning from '@wordpress/warning';

/**
* Internal dependencies
*/
import type { ThemeInputValues, ThemeOutputValues } from './types';
import { COLORS } from '../utils';

extend( [ namesPlugin, a11yPlugin ] );

export function generateThemeVariables(
inputs: ThemeInputValues
): ThemeOutputValues {
validateInputs( inputs );

const generatedColors = {
...generateAccentDependentColors( inputs.accent ),
...generateBackgroundDependentColors( inputs.background ),
};

warnContrastIssues( checkContrasts( inputs, generatedColors ) );

return { colors: generatedColors };
}

function validateInputs( inputs: ThemeInputValues ) {
for ( const [ key, value ] of Object.entries( inputs ) ) {
if ( typeof value !== 'undefined' && ! colord( value ).isValid() ) {
warning(
`wp.components.Theme: "${ value }" is not a valid color value for the '${ key }' prop.`
);
}
}
}

export function checkContrasts(
inputs: ThemeInputValues,
outputs: ThemeOutputValues[ 'colors' ]
) {
const background = inputs.background || COLORS.white;
const accent = inputs.accent || '#007cba';
const foreground = outputs.foreground || COLORS.gray[ 900 ];
const gray = outputs.gray || COLORS.gray;

return {
accent: colord( background ).isReadable( accent )
? undefined
: `The background color ("${ background }") does not have sufficient contrast against the accent color ("${ accent }").`,
foreground: colord( background ).isReadable( foreground )
? undefined
: `The background color provided ("${ background }") does not have sufficient contrast against the standard foreground colors.`,
grays:
colord( background ).contrast( gray[ 600 ] ) >= 3 &&
colord( background ).contrast( gray[ 700 ] ) >= 4.5
? undefined
: `The background color provided ("${ background }") cannot generate a set of grayscale foreground colors with sufficient contrast. Try adjusting the color to be lighter or darker.`,
};
}

function warnContrastIssues( issues: ReturnType< typeof checkContrasts > ) {
for ( const error of Object.values( issues ) ) {
if ( error ) {
warning( 'wp.components.Theme: ' + error );
}
}
}

function generateAccentDependentColors( accent?: string ) {
if ( ! accent ) return {};

return {
accent,
accentDarker10: colord( accent ).darken( 0.1 ).toHex(),
accentDarker20: colord( accent ).darken( 0.2 ).toHex(),
accentInverted: getForegroundForColor( accent ),
};
}

function generateBackgroundDependentColors( background?: string ) {
if ( ! background ) return {};

const foreground = getForegroundForColor( background );

return {
background,
foreground,
foregroundInverted: getForegroundForColor( foreground ),
gray: generateShades( background, foreground ),
};
}

function getForegroundForColor( color: string ) {
return colord( color ).isDark() ? COLORS.white : COLORS.gray[ 900 ];
}

export function generateShades( background: string, foreground: string ) {
// How much darkness you need to add to #fff to get the COLORS.gray[n] color
const SHADES = {
100: 0.06,
200: 0.121,
300: 0.132,
400: 0.2,
600: 0.42,
700: 0.543,
800: 0.821,
};

// Darkness of COLORS.gray[ 900 ], relative to #fff
const limit = 0.884;

const direction = colord( background ).isDark() ? 'lighten' : 'darken';

// Lightness delta between the background and foreground colors
const range =
Math.abs(
colord( background ).toHsl().l - colord( foreground ).toHsl().l
) / 100;

const result: Record< number, string > = {};

Object.entries( SHADES ).forEach( ( [ key, value ] ) => {
result[ parseInt( key ) ] = colord( background )
[ direction ]( ( value / limit ) * range )
.toHex();
} );

return result as NonNullable< ThemeOutputValues[ 'colors' ][ 'gray' ] >;
}
Loading

0 comments on commit b82058c

Please sign in to comment.