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

Components: Add themeable background color #45466

Merged
merged 26 commits into from
Nov 30, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,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,
};

ciampo marked this conversation as resolved.
Show resolved Hide resolved
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;
ciampo marked this conversation as resolved.
Show resolved Hide resolved
}

&: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 ) {
ciampo marked this conversation as resolved.
Show resolved Hide resolved
// 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,
Copy link
Contributor

Choose a reason for hiding this comment

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

Out of curiosity, any reason why we don't generate a gray-900 color?

Copy link
Member Author

Choose a reason for hiding this comment

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

In the context of our color system, gray-900 is always the same as foreground. So it was redundant.

Copy link
Contributor

Choose a reason for hiding this comment

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

Are these opacity values? Just curious on how the hex colors are calculated. This is cool stuff.

Copy link
Member Author

Choose a reason for hiding this comment

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

These are "darkness" values, i.e. the inverse of the Lightness value in HSL.

gray-100 #f0f0f0 is hsl(0, 0%, 94%) (1 - 0.94 = 0.06, etc)
gray-200 #e0e0e0 is hsl(0, 0%, 88%) and so on...

That said, we might eventually switch from HSL to LCH for internal calculations so the lightness differences are a bit more reliable than HSL.

Copy link
Contributor

@jasmussen jasmussen Dec 5, 2022

Choose a reason for hiding this comment

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

Very cool, thank you! I like the sound of that, in the past I explored HSL for component theming, and the opportunities are pretty cool. That's not to say the approach I explored there is the right one — just that it's nice to be able to feed a single hue value, and have the rest refer to that.

Edit: Especially useful if it means we can retire light/dark versions of the spot color, so we have just a single spot color.

};

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

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

Choose a reason for hiding this comment

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

Currently, gray-100 could be the brightest or the darkest color in the scale of grays, depending on whether the background color is classified as "dark" by colord.

Could this cause confusion in the consumers of the theme? My experience as a developer has taught me that usually 100 is always associated to a brighter color, and 900 to a darker.

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 don't have an alternative for that problem specifically. Though, I'm expecting that most usages won't be using these gray colors directly, but instead through more semantically named variables (e.g. border, lightBorder, secondaryText, etc).

Copy link
Contributor

Choose a reason for hiding this comment

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

Ok. Let's get a feeling for it and see if we need to make any adjustments (at most we could add a line to our docs ?)


// 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