Themes: V8 Theme model (#32342)

* Updated

* Progress

* Progress

* Added spacings

* Updated rich color to be more descriptibe

* Added more to getRichColor to showcase how it would work

* Added more to getRichColor to showcase how it would work

* Updated

* Started on storybook

* Rename to palette

* Storybook progress

* Minor update

* Progress

* Progress

* removed unused import

* Updated

* Progress

* Added typography to new theme model

* Added shadows and zindex to new theme

* Updated based on last discussions

* Updated

* Rename shadows

* Moving storybook to new theme, renaming stories and moving to single category

* Updated snapshot

* Updated jsdoc state tags

* Reducing annonying errors
This commit is contained in:
Torkel Ödegaard
2021-04-07 19:13:00 +02:00
committed by GitHub
parent a9e90b5088
commit 4527f712e0
37 changed files with 2025 additions and 55 deletions

View File

@@ -52,6 +52,7 @@
"rollup-plugin-typescript2": "0.29.0",
"rollup-plugin-visualizer": "4.2.0",
"sinon": "8.1.1",
"typescript": "4.1.2"
"typescript": "4.1.2",
"tinycolor2": "1.4.1"
}
}

View File

@@ -13,6 +13,7 @@ export * from './text';
export * from './valueFormats';
export * from './field';
export * from './events';
export * from './themes';
export {
ValueMatcherOptions,
BasicValueMatcherOptions,

View File

@@ -0,0 +1,56 @@
/** @beta */
export interface ThemeBreakpointValues {
xs: number;
sm: number;
md: number;
lg: number;
xl: number;
xxl: number;
}
/** @beta */
export type ThemeBreakpointsKey = keyof ThemeBreakpointValues;
/** @beta */
export interface ThemeBreakpoints {
values: ThemeBreakpointValues;
keys: string[];
unit: string;
up: (key: ThemeBreakpointsKey) => string;
down: (key: ThemeBreakpointsKey) => string;
}
/** @internal */
export function createBreakpoints(): ThemeBreakpoints {
const step = 5;
const keys = ['xs', 'sm', 'md', 'lg', 'xl', 'xxl'];
const unit = 'px';
const values: ThemeBreakpointValues = {
xs: 0,
sm: 544,
md: 769, // 1 more than regular ipad in portrait
lg: 992,
xl: 1200,
xxl: 1440,
};
function up(key: ThemeBreakpointsKey | number) {
const value = typeof key === 'number' ? key : values[key];
return `@media (min-width:${value}${unit})`;
}
function down(key: ThemeBreakpointsKey | number) {
const value = typeof key === 'number' ? key : values[key];
return `@media (max-width:${value - step / 100}${unit})`;
}
// TODO add functions for between and only
return {
values,
up,
down,
keys,
unit,
};
}

View File

@@ -0,0 +1,402 @@
import {
recomposeColor,
hexToRgb,
rgbToHex,
hslToRgb,
darken,
decomposeColor,
emphasize,
alpha,
getContrastRatio,
getLuminance,
lighten,
} from './colorManipulator';
describe('utils/colorManipulator', () => {
const origError = console.error;
const consoleErrorMock = jest.fn();
afterEach(() => (console.error = origError));
beforeEach(() => (console.error = consoleErrorMock));
describe('recomposeColor', () => {
it('converts a decomposed rgb color object to a string` ', () => {
expect(
recomposeColor({
type: 'rgb',
values: [255, 255, 255],
})
).toEqual('rgb(255, 255, 255)');
});
it('converts a decomposed rgba color object to a string` ', () => {
expect(
recomposeColor({
type: 'rgba',
values: [255, 255, 255, 0.5],
})
).toEqual('rgba(255, 255, 255, 0.5)');
});
it('converts a decomposed CSS4 color object to a string` ', () => {
expect(
recomposeColor({
type: 'color',
colorSpace: 'display-p3',
values: [0.5, 0.3, 0.2],
})
).toEqual('color(display-p3 0.5 0.3 0.2)');
});
it('converts a decomposed hsl color object to a string` ', () => {
expect(
recomposeColor({
type: 'hsl',
values: [100, 50, 25],
})
).toEqual('hsl(100, 50%, 25%)');
});
it('converts a decomposed hsla color object to a string` ', () => {
expect(
recomposeColor({
type: 'hsla',
values: [100, 50, 25, 0.5],
})
).toEqual('hsla(100, 50%, 25%, 0.5)');
});
});
describe('hexToRgb', () => {
it('converts a short hex color to an rgb color` ', () => {
expect(hexToRgb('#9f3')).toEqual('rgb(153, 255, 51)');
});
it('converts a long hex color to an rgb color` ', () => {
expect(hexToRgb('#a94fd3')).toEqual('rgb(169, 79, 211)');
});
it('converts a long alpha hex color to an argb color` ', () => {
expect(hexToRgb('#111111f8')).toEqual('rgba(17, 17, 17, 0.973)');
});
});
describe('rgbToHex', () => {
it('converts an rgb color to a hex color` ', () => {
expect(rgbToHex('rgb(169, 79, 211)')).toEqual('#a94fd3');
});
it('idempotent', () => {
expect(rgbToHex('#A94FD3')).toEqual('#A94FD3');
});
});
describe('hslToRgb', () => {
it('converts an hsl color to an rgb color` ', () => {
expect(hslToRgb('hsl(281, 60%, 57%)')).toEqual('rgb(169, 80, 211)');
});
it('converts an hsla color to an rgba color` ', () => {
expect(hslToRgb('hsla(281, 60%, 57%, 0.5)')).toEqual('rgba(169, 80, 211, 0.5)');
});
it('allow to convert values only', () => {
expect(hslToRgb(decomposeColor('hsl(281, 60%, 57%)'))).toEqual('rgb(169, 80, 211)');
});
});
describe('decomposeColor', () => {
it('converts an rgb color string to an object with `type` and `value` keys', () => {
const { type, values } = decomposeColor('rgb(255, 255, 255)');
expect(type).toEqual('rgb');
expect(values).toEqual([255, 255, 255]);
});
it('converts an rgba color string to an object with `type` and `value` keys', () => {
const { type, values } = decomposeColor('rgba(255, 255, 255, 0.5)');
expect(type).toEqual('rgba');
expect(values).toEqual([255, 255, 255, 0.5]);
});
it('converts an hsl color string to an object with `type` and `value` keys', () => {
const { type, values } = decomposeColor('hsl(100, 50%, 25%)');
expect(type).toEqual('hsl');
expect(values).toEqual([100, 50, 25]);
});
it('converts an hsla color string to an object with `type` and `value` keys', () => {
const { type, values } = decomposeColor('hsla(100, 50%, 25%, 0.5)');
expect(type).toEqual('hsla');
expect(values).toEqual([100, 50, 25, 0.5]);
});
it('converts CSS4 color with color space display-3', () => {
const { type, values, colorSpace } = decomposeColor('color(display-p3 0 1 0)');
expect(type).toEqual('color');
expect(colorSpace).toEqual('display-p3');
expect(values).toEqual([0, 1, 0]);
});
it('converts an alpha CSS4 color with color space display-3', () => {
const { type, values, colorSpace } = decomposeColor('color(display-p3 0 1 0 /0.4)');
expect(type).toEqual('color');
expect(colorSpace).toEqual('display-p3');
expect(values).toEqual([0, 1, 0, 0.4]);
});
it('should throw error with inexistent color color space', () => {
const decimposeWithError = () => decomposeColor('color(foo 0 1 0)');
expect(decimposeWithError).toThrow();
});
it('idempotent', () => {
const output1 = decomposeColor('hsla(100, 50%, 25%, 0.5)');
const output2 = decomposeColor(output1);
expect(output1).toEqual(output2);
});
it('converts rgba hex', () => {
const decomposed = decomposeColor('#111111f8');
expect(decomposed).toEqual({
type: 'rgba',
colorSpace: undefined,
values: [17, 17, 17, 0.973],
});
});
});
describe('getContrastRatio', () => {
it('returns a ratio for black : white', () => {
expect(getContrastRatio('#000', '#FFF')).toEqual(21);
});
it('returns a ratio for black : black', () => {
expect(getContrastRatio('#000', '#000')).toEqual(1);
});
it('returns a ratio for white : white', () => {
expect(getContrastRatio('#FFF', '#FFF')).toEqual(1);
});
it('returns a ratio for dark-grey : light-grey', () => {
//expect(getContrastRatio('#707070', '#E5E5E5'))to.be.approximately(3.93, 0.01);
});
it('returns a ratio for black : light-grey', () => {
//expect(getContrastRatio('#000', '#888')).to.be.approximately(5.92, 0.01);
});
});
describe('getLuminance', () => {
it('returns a valid luminance for rgb black', () => {
expect(getLuminance('rgba(0, 0, 0)')).toEqual(0);
expect(getLuminance('rgb(0, 0, 0)')).toEqual(0);
expect(getLuminance('color(display-p3 0 0 0)')).toEqual(0);
});
it('returns a valid luminance for rgb white', () => {
expect(getLuminance('rgba(255, 255, 255)')).toEqual(1);
expect(getLuminance('rgb(255, 255, 255)')).toEqual(1);
});
it('returns a valid luminance for rgb mid-grey', () => {
expect(getLuminance('rgba(127, 127, 127)')).toEqual(0.212);
expect(getLuminance('rgb(127, 127, 127)')).toEqual(0.212);
});
it('returns a valid luminance for an rgb color', () => {
expect(getLuminance('rgb(255, 127, 0)')).toEqual(0.364);
});
it('returns a valid luminance from an hsl color', () => {
expect(getLuminance('hsl(100, 100%, 50%)')).toEqual(0.735);
});
it('returns an equal luminance for the same color in different formats', () => {
const hsl = 'hsl(100, 100%, 50%)';
const rgb = 'rgb(85, 255, 0)';
expect(getLuminance(hsl)).toEqual(getLuminance(rgb));
});
it('returns a valid luminance from an CSS4 color', () => {
expect(getLuminance('color(display-p3 1 1 0.1)')).toEqual(0.929);
});
it('throw on invalid colors', () => {
expect(() => {
getLuminance('black');
}).toThrowError(/Unsupported 'black' color/);
});
});
describe('emphasize', () => {
it('lightens a dark rgb color with the coefficient provided', () => {
expect(emphasize('rgb(1, 2, 3)', 0.4)).toEqual(lighten('rgb(1, 2, 3)', 0.4));
});
it('darkens a light rgb color with the coefficient provided', () => {
expect(emphasize('rgb(250, 240, 230)', 0.3)).toEqual(darken('rgb(250, 240, 230)', 0.3));
});
it('lightens a dark rgb color with the coefficient 0.15 by default', () => {
expect(emphasize('rgb(1, 2, 3)')).toEqual(lighten('rgb(1, 2, 3)', 0.15));
});
it('darkens a light rgb color with the coefficient 0.15 by default', () => {
expect(emphasize('rgb(250, 240, 230)')).toEqual(darken('rgb(250, 240, 230)', 0.15));
});
it('lightens a dark CSS4 color with the coefficient 0.15 by default', () => {
expect(emphasize('color(display-p3 0.1 0.1 0.1)')).toEqual(lighten('color(display-p3 0.1 0.1 0.1)', 0.15));
});
it('darkens a light CSS4 color with the coefficient 0.15 by default', () => {
expect(emphasize('color(display-p3 1 1 0.1)')).toEqual(darken('color(display-p3 1 1 0.1)', 0.15));
});
});
describe('alpha', () => {
it('converts an rgb color to an rgba color with the value provided', () => {
expect(alpha('rgb(1, 2, 3)', 0.4)).toEqual('rgba(1, 2, 3, 0.4)');
});
it('updates an CSS4 color with the alpha value provided', () => {
expect(alpha('color(display-p3 1 2 3)', 0.4)).toEqual('color(display-p3 1 2 3 /0.4)');
});
it('updates an rgba color with the alpha value provided', () => {
expect(alpha('rgba(255, 0, 0, 0.2)', 0.5)).toEqual('rgba(255, 0, 0, 0.5)');
});
it('converts an hsl color to an hsla color with the value provided', () => {
expect(alpha('hsl(0, 100%, 50%)', 0.1)).toEqual('hsla(0, 100%, 50%, 0.1)');
});
it('updates an hsla color with the alpha value provided', () => {
expect(alpha('hsla(0, 100%, 50%, 0.2)', 0.5)).toEqual('hsla(0, 100%, 50%, 0.5)');
});
it('throw on invalid colors', () => {
expect(() => {
alpha('white', 0.4);
}).toThrowError(/Unsupported 'white' color/);
});
});
describe('darken', () => {
it("doesn't modify rgb black", () => {
expect(darken('rgb(0, 0, 0)', 0.1)).toEqual('rgb(0, 0, 0)');
});
it("doesn't overshoot if an above-range coefficient is supplied", () => {
expect(darken('rgb(0, 127, 255)', 1.5)).toEqual('rgb(0, 0, 0)');
expect(consoleErrorMock).toHaveBeenCalledWith('The value provided 1.5 is out of range [0, 1].');
});
it("doesn't overshoot if a below-range coefficient is supplied", () => {
expect(darken('rgb(0, 127, 255)', -0.1)).toEqual('rgb(0, 127, 255)');
expect(consoleErrorMock).toHaveBeenCalledWith('The value provided 1.5 is out of range [0, 1].');
});
it('darkens rgb white to black when coefficient is 1', () => {
expect(darken('rgb(255, 255, 255)', 1)).toEqual('rgb(0, 0, 0)');
});
it('retains the alpha value in an rgba color', () => {
expect(darken('rgb(0, 0, 0, 0.5)', 0.1)).toEqual('rgb(0, 0, 0, 0.5)');
});
it('darkens rgb white by 10% when coefficient is 0.1', () => {
expect(darken('rgb(255, 255, 255)', 0.1)).toEqual('rgb(229, 229, 229)');
});
it('darkens rgb red by 50% when coefficient is 0.5', () => {
expect(darken('rgb(255, 0, 0)', 0.5)).toEqual('rgb(127, 0, 0)');
});
it('darkens rgb grey by 50% when coefficient is 0.5', () => {
expect(darken('rgb(127, 127, 127)', 0.5)).toEqual('rgb(63, 63, 63)');
});
it("doesn't modify rgb colors when coefficient is 0", () => {
expect(darken('rgb(255, 255, 255)', 0)).toEqual('rgb(255, 255, 255)');
});
it('darkens hsl red by 50% when coefficient is 0.5', () => {
expect(darken('hsl(0, 100%, 50%)', 0.5)).toEqual('hsl(0, 100%, 25%)');
});
it("doesn't modify hsl colors when coefficient is 0", () => {
expect(darken('hsl(0, 100%, 50%)', 0)).toEqual('hsl(0, 100%, 50%)');
});
it("doesn't modify hsl colors when l is 0%", () => {
expect(darken('hsl(0, 50%, 0%)', 0.5)).toEqual('hsl(0, 50%, 0%)');
});
it('darkens CSS4 color red by 50% when coefficient is 0.5', () => {
expect(darken('color(display-p3 1 0 0)', 0.5)).toEqual('color(display-p3 0.5 0 0)');
});
it("doesn't modify CSS4 color when coefficient is 0", () => {
expect(darken('color(display-p3 1 0 0)', 0)).toEqual('color(display-p3 1 0 0)');
});
});
describe('lighten', () => {
it("doesn't modify rgb white", () => {
expect(lighten('rgb(255, 255, 255)', 0.1)).toEqual('rgb(255, 255, 255)');
});
it("doesn't overshoot if an above-range coefficient is supplied", () => {
expect(lighten('rgb(0, 127, 255)', 1.5)).toEqual('rgb(255, 255, 255)');
});
it("doesn't overshoot if a below-range coefficient is supplied", () => {
expect(lighten('rgb(0, 127, 255)', -0.1)).toEqual('rgb(0, 127, 255)');
});
it('lightens rgb black to white when coefficient is 1', () => {
expect(lighten('rgb(0, 0, 0)', 1)).toEqual('rgb(255, 255, 255)');
});
it('retains the alpha value in an rgba color', () => {
expect(lighten('rgb(255, 255, 255, 0.5)', 0.1)).toEqual('rgb(255, 255, 255, 0.5)');
});
it('lightens rgb black by 10% when coefficient is 0.1', () => {
expect(lighten('rgb(0, 0, 0)', 0.1)).toEqual('rgb(25, 25, 25)');
});
it('lightens rgb red by 50% when coefficient is 0.5', () => {
expect(lighten('rgb(255, 0, 0)', 0.5)).toEqual('rgb(255, 127, 127)');
});
it('lightens rgb grey by 50% when coefficient is 0.5', () => {
expect(lighten('rgb(127, 127, 127)', 0.5)).toEqual('rgb(191, 191, 191)');
});
it("doesn't modify rgb colors when coefficient is 0", () => {
expect(lighten('rgb(127, 127, 127)', 0)).toEqual('rgb(127, 127, 127)');
});
it('lightens hsl red by 50% when coefficient is 0.5', () => {
expect(lighten('hsl(0, 100%, 50%)', 0.5)).toEqual('hsl(0, 100%, 75%)');
});
it("doesn't modify hsl colors when coefficient is 0", () => {
expect(lighten('hsl(0, 100%, 50%)', 0)).toEqual('hsl(0, 100%, 50%)');
});
it("doesn't modify hsl colors when `l` is 100%", () => {
expect(lighten('hsl(0, 50%, 100%)', 0.5)).toEqual('hsl(0, 50%, 100%)');
});
it('lightens CSS4 color red by 50% when coefficient is 0.5', () => {
expect(lighten('color(display-p3 1 0 0)', 0.5)).toEqual('color(display-p3 1 0.5 0.5)');
});
it("doesn't modify CSS4 color when coefficient is 0", () => {
expect(lighten('color(display-p3 1 0 0)', 0)).toEqual('color(display-p3 1 0 0)');
});
});
});

View File

@@ -0,0 +1,286 @@
// Code based on Material-UI
// https://github.com/mui-org/material-ui/blob/1b096070faf102281f8e3c4f9b2bf50acf91f412/packages/material-ui/src/styles/colorManipulator.js#L97
// MIT License Copyright (c) 2014 Call-Em-All
/**
* Returns a number whose value is limited to the given range.
* @param {number} value The value to be clamped
* @param {number} min The lower boundary of the output range
* @param {number} max The upper boundary of the output range
* @returns {number} A number in the range [min, max]
*/
function clamp(value: number, min = 0, max = 1) {
if (process.env.NODE_ENV !== 'production') {
if (value < min || value > max) {
console.error(`The value provided ${value} is out of range [${min}, ${max}].`);
}
}
return Math.min(Math.max(min, value), max);
}
/**
* Converts a color from CSS hex format to CSS rgb format.
* @param {string} color - Hex color, i.e. #nnn or #nnnnnn
* @returns {string} A CSS rgb color string
*/
export function hexToRgb(color: string) {
color = color.substr(1);
const re = new RegExp(`.{1,${color.length >= 6 ? 2 : 1}}`, 'g');
let colors = color.match(re);
if (colors && colors[0].length === 1) {
colors = colors.map((n) => n + n);
}
return colors
? `rgb${colors.length === 4 ? 'a' : ''}(${colors
.map((n, index) => {
return index < 3 ? parseInt(n, 16) : Math.round((parseInt(n, 16) / 255) * 1000) / 1000;
})
.join(', ')})`
: '';
}
function intToHex(int: number) {
const hex = int.toString(16);
return hex.length === 1 ? `0${hex}` : hex;
}
/**
* Converts a color from CSS rgb format to CSS hex format.
* @param {string} color - RGB color, i.e. rgb(n, n, n)
* @returns {string} A CSS rgb color string, i.e. #nnnnnn
*/
export function rgbToHex(color: string) {
// Idempotent
if (color.indexOf('#') === 0) {
return color;
}
const { values } = decomposeColor(color);
return `#${values.map((n: number) => intToHex(n)).join('')}`;
}
/**
* Converts a color from hsl format to rgb format.
* @param {string} color - HSL color values
* @returns {string} rgb color values
*/
export function hslToRgb(color: string | DecomposeColor) {
const parts = decomposeColor(color);
const { values } = parts;
const h = values[0];
const s = values[1] / 100;
const l = values[2] / 100;
const a = s * Math.min(l, 1 - l);
const f = (n: number, k = (n + h / 30) % 12) => l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
let type = 'rgb';
const rgb = [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
if (parts.type === 'hsla') {
type += 'a';
rgb.push(values[3]);
}
return recomposeColor({ type, values: rgb });
}
/**
* Returns an object with the type and values of a color.
*
* Note: Does not support rgb % values.
* @param {string} color - CSS color, i.e. one of: #nnn, #nnnnnn, rgb(), rgba(), hsl(), hsla()
* @returns {object} - A MUI color object: {type: string, values: number[]}
*/
export function decomposeColor(color: string | DecomposeColor): DecomposeColor {
// Idempotent
if (typeof color !== 'string') {
return color;
}
if (color.charAt(0) === '#') {
return decomposeColor(hexToRgb(color));
}
const marker = color.indexOf('(');
const type = color.substring(0, marker);
if (['rgb', 'rgba', 'hsl', 'hsla', 'color'].indexOf(type) === -1) {
throw new Error(
`Unsupported '${color}' color. The following formats are supported: #nnn, #nnnnnn, rgb(), rgba(), hsl(), hsla(), color()`
);
}
let values: any = color.substring(marker + 1, color.length - 1);
let colorSpace;
if (type === 'color') {
values = values.split(' ');
colorSpace = values.shift();
if (values.length === 4 && values[3].charAt(0) === '/') {
values[3] = values[3].substr(1);
}
if (['srgb', 'display-p3', 'a98-rgb', 'prophoto-rgb', 'rec-2020'].indexOf(colorSpace) === -1) {
throw new Error(
`Unsupported ${colorSpace} color space. The following color spaces are supported: srgb, display-p3, a98-rgb, prophoto-rgb, rec-2020.`
);
}
} else {
values = values.split(',');
}
values = values.map((value: string) => parseFloat(value));
return { type, values, colorSpace };
}
/**
* Converts a color object with type and values to a string.
* @param {object} color - Decomposed color
* @param {string} color.type - One of: 'rgb', 'rgba', 'hsl', 'hsla'
* @param {array} color.values - [n,n,n] or [n,n,n,n]
* @returns {string} A CSS color string
*/
export function recomposeColor(color: DecomposeColor) {
const { type, colorSpace } = color;
let values: any = color.values;
if (type.indexOf('rgb') !== -1) {
// Only convert the first 3 values to int (i.e. not alpha)
values = values.map((n: string, i: number) => (i < 3 ? parseInt(n, 10) : n));
} else if (type.indexOf('hsl') !== -1) {
values[1] = `${values[1]}%`;
values[2] = `${values[2]}%`;
}
if (type.indexOf('color') !== -1) {
values = `${colorSpace} ${values.join(' ')}`;
} else {
values = `${values.join(', ')}`;
}
return `${type}(${values})`;
}
/**
* Calculates the contrast ratio between two colors.
*
* Formula: https://www.w3.org/TR/WCAG20-TECHS/G17.html#G17-tests
* @param {string} foreground - CSS color, i.e. one of: #nnn, #nnnnnn, rgb(), rgba(), hsl(), hsla()
* @param {string} background - CSS color, i.e. one of: #nnn, #nnnnnn, rgb(), rgba(), hsl(), hsla()
* @returns {number} A contrast ratio value in the range 0 - 21.
*/
export function getContrastRatio(foreground: string, background: string) {
const lumA = getLuminance(foreground);
const lumB = getLuminance(background);
return (Math.max(lumA, lumB) + 0.05) / (Math.min(lumA, lumB) + 0.05);
}
/**
* The relative brightness of any point in a color space,
* normalized to 0 for darkest black and 1 for lightest white.
*
* Formula: https://www.w3.org/TR/WCAG20-TECHS/G17.html#G17-tests
* @param {string} color - CSS color, i.e. one of: #nnn, #nnnnnn, rgb(), rgba(), hsl(), hsla(), color()
* @returns {number} The relative brightness of the color in the range 0 - 1
*/
export function getLuminance(color: string) {
const parts = decomposeColor(color);
let rgb = parts.type === 'hsl' ? decomposeColor(hslToRgb(color)).values : parts.values;
const rgbNumbers = rgb.map((val: any) => {
if (parts.type !== 'color') {
val /= 255; // normalized
}
return val <= 0.03928 ? val / 12.92 : ((val + 0.055) / 1.055) ** 2.4;
});
// Truncate at 3 digits
return Number((0.2126 * rgbNumbers[0] + 0.7152 * rgbNumbers[1] + 0.0722 * rgbNumbers[2]).toFixed(3));
}
/**
* Darken or lighten a color, depending on its luminance.
* Light colors are darkened, dark colors are lightened.
* @param {string} color - CSS color, i.e. one of: #nnn, #nnnnnn, rgb(), rgba(), hsl(), hsla(), color()
* @param {number} coefficient=0.15 - multiplier in the range 0 - 1
* @returns {string} A CSS color string. Hex input values are returned as rgb
*/
export function emphasize(color: string, coefficient = 0.15) {
return getLuminance(color) > 0.5 ? darken(color, coefficient) : lighten(color, coefficient);
}
/**
* Set the absolute transparency of a color.
* Any existing alpha values are overwritten.
* @param {string} color - CSS color, i.e. one of: #nnn, #nnnnnn, rgb(), rgba(), hsl(), hsla(), color()
* @param {number} value - value to set the alpha channel to in the range 0 - 1
* @returns {string} A CSS color string. Hex input values are returned as rgb
*/
export function alpha(color: string, value: number) {
const parts = decomposeColor(color);
value = clamp(value);
if (parts.type === 'rgb' || parts.type === 'hsl') {
parts.type += 'a';
}
if (parts.type === 'color') {
parts.values[3] = `/${value}`;
} else {
parts.values[3] = value;
}
return recomposeColor(parts);
}
/**
* Darkens a color.
* @param {string} color - CSS color, i.e. one of: #nnn, #nnnnnn, rgb(), rgba(), hsl(), hsla(), color()
* @param {number} coefficient - multiplier in the range 0 - 1
* @returns {string} A CSS color string. Hex input values are returned as rgb
*/
export function darken(color: string, coefficient: number) {
const parts = decomposeColor(color);
coefficient = clamp(coefficient);
if (parts.type.indexOf('hsl') !== -1) {
parts.values[2] *= 1 - coefficient;
} else if (parts.type.indexOf('rgb') !== -1 || parts.type.indexOf('color') !== -1) {
for (let i = 0; i < 3; i += 1) {
parts.values[i] *= 1 - coefficient;
}
}
return recomposeColor(parts);
}
/**
* Lightens a color.
* @param {string} color - CSS color, i.e. one of: #nnn, #nnnnnn, rgb(), rgba(), hsl(), hsla(), color()
* @param {number} coefficient - multiplier in the range 0 - 1
* @returns {string} A CSS color string. Hex input values are returned as rgb
*/
export function lighten(color: string, coefficient: number) {
const parts = decomposeColor(color);
coefficient = clamp(coefficient);
if (parts.type.indexOf('hsl') !== -1) {
parts.values[2] += (100 - parts.values[2]) * coefficient;
} else if (parts.type.indexOf('rgb') !== -1) {
for (let i = 0; i < 3; i += 1) {
parts.values[i] += (255 - parts.values[i]) * coefficient;
}
} else if (parts.type.indexOf('color') !== -1) {
for (let i = 0; i < 3; i += 1) {
parts.values[i] += (1 - parts.values[i]) * coefficient;
}
}
return recomposeColor(parts);
}
interface DecomposeColor {
type: string;
values: any;
colorSpace?: string;
}

View File

@@ -0,0 +1,49 @@
export const colors = {
white: '#fff',
black: '#000',
// New greys palette used by next-gen form elements
gray98: '#f7f8fa',
gray97: '#f1f5f9',
gray95: '#e9edf2',
gray90: '#dce1e6',
gray85: '#c7d0d9',
gray70: '#9fa7b3',
gray60: '#7b8087',
gray33: '#464c54',
gray25: '#2c3235',
gray15: '#202226',
gray10: '#141619',
gray05: '#0b0c0e',
blueDark1: '#3658E2',
blueDark2: '#5B93FF',
blueLight1: '#276EF1',
blueLight2: '#93BDFE',
blueLight3: '#1F62E0',
redDark1: '#D10E5C',
redDark2: '#FF5286',
redLight1: '#CF0E5B',
redLight2: '#FF5286',
green1: '#13875D',
green2: '#6CCF8E',
// New reds palette used by next-gen form elements
red88: '#e02f44', // redBase
// below taken from dark theme
redShade: '#c4162a',
redBase: '#e02f44',
greenBase: '#299c46',
greenShade: '#23843b',
red: '#d44a3a',
yellow: '#ecbb13',
purple: '#9933cc',
variable: '#32d1df',
orange: '#eb7b18',
orangeDark: '#ff780a',
};

View File

@@ -0,0 +1,27 @@
/** @beta */
export interface ThemeComponents {
/** Applies to normal buttons, inputs, radio buttons, etc */
height: {
sm: number;
md: number;
lg: number;
};
panel: {
padding: number;
headerHeight: number;
};
}
export function createComponents(): ThemeComponents {
return {
height: {
sm: 3,
md: 4,
lg: 6,
},
panel: {
padding: 1,
headerHeight: 4,
},
};
}

View File

@@ -0,0 +1,17 @@
import { createPalette } from './createPalette';
describe('createColors', () => {
it('Should enrich colors', () => {
const palette = createPalette({});
expect(palette.primary.name).toBe('primary');
});
it('Should allow overrides', () => {
const palette = createPalette({
primary: {
main: '#FF0000',
},
});
expect(palette.primary.main).toBe('#FF0000');
});
});

View File

@@ -0,0 +1,250 @@
import { merge } from 'lodash';
import { emphasize, getContrastRatio } from './colorManipulator';
import { colors } from './colors';
import { DeepPartial, ThemePaletteColor } from './types';
/** @internal */
export type ThemePaletteMode = 'light' | 'dark';
/** @internal */
export interface ThemePaletteBase<TColor> {
mode: ThemePaletteMode;
primary: TColor;
secondary: TColor;
info: TColor;
error: TColor;
success: TColor;
warning: TColor;
text: {
primary: string;
secondary: string;
disabled: string;
link: string;
/** Used for auto white or dark text on colored backgrounds */
maxContrast: string;
};
layer0: string;
layer1: string;
layer2: string;
border0: string;
border1: string;
border2: string;
formComponent: {
background: string;
text: string;
border: string;
disabledBackground: string;
disabledText: string;
};
hoverFactor: number;
contrastThreshold: number;
tonalOffset: number;
}
/** @beta */
export interface ThemePalette extends ThemePaletteBase<ThemePaletteColor> {
/** Returns a text color for the background */
getContrastText(background: string): string;
/* Retruns a hover color for any default color */
getHoverColor(defaultColor: string): string;
}
/** @internal */
export type ThemePaletteInput = DeepPartial<ThemePaletteBase<ThemePaletteColor>>;
class DarkPalette implements ThemePaletteBase<Partial<ThemePaletteColor>> {
mode: ThemePaletteMode = 'dark';
text = {
primary: 'rgba(255, 255, 255, 0.75)',
secondary: 'rgba(255, 255, 255, 0.50)',
disabled: 'rgba(255, 255, 255, 0.3)',
link: colors.blueDark2,
maxContrast: colors.white,
};
primary = {
main: colors.blueDark1,
border: colors.blueDark2,
text: colors.blueDark2,
};
secondary = {
main: 'rgba(255,255,255,0.1)',
contrastText: 'rgba(255, 255, 255, 0.8)',
};
info = this.primary;
error = {
main: colors.redDark1,
border: colors.redDark2,
text: colors.redDark2,
};
success = {
main: colors.green1,
text: colors.green2,
border: colors.green2,
};
warning = {
main: colors.orange,
};
layer0 = colors.gray05;
layer1 = colors.gray10;
layer2 = colors.gray15;
border0 = colors.gray15;
border1 = colors.gray25;
border2 = colors.gray33;
formComponent = {
background: this.layer0,
border: this.border1,
text: this.text.primary,
disabledText: this.text.disabled,
disabledBackground: colors.gray10,
};
contrastThreshold = 3;
hoverFactor = 0.15;
tonalOffset = 0.1;
}
class LightPalette implements ThemePaletteBase<Partial<ThemePaletteColor>> {
mode: ThemePaletteMode = 'light';
primary = {
main: colors.blueLight1,
border: colors.blueLight3,
text: colors.blueLight3,
};
secondary = {
main: 'rgba(0,0,0,0.2)',
contrastText: 'rgba(0, 0, 0, 0.87)',
};
info = {
main: colors.blueLight1,
text: colors.blueLight3,
};
error = {
main: colors.redLight1,
text: colors.redLight2,
border: colors.redLight2,
};
success = {
main: colors.greenBase,
};
warning = {
main: colors.orange,
};
text = {
primary: 'rgba(0, 0, 0, 0.87)',
secondary: 'rgba(0, 0, 0, 0.54)',
disabled: 'rgba(0, 0, 0, 0.38)',
link: this.primary.text,
maxContrast: colors.black,
};
layer0 = colors.gray98;
layer1 = colors.white;
layer2 = colors.gray97;
border0 = colors.gray90;
border1 = colors.gray85;
border2 = colors.gray70;
formComponent = {
background: this.layer1,
border: this.border2,
text: this.text.primary,
disabledBackground: colors.gray95,
disabledText: this.text.disabled,
};
contrastThreshold = 3;
hoverFactor = 0.15;
tonalOffset = 0.2;
}
export function createPalette(palette: ThemePaletteInput): ThemePalette {
const dark = new DarkPalette();
const light = new LightPalette();
const base = (palette.mode ?? 'dark') === 'dark' ? dark : light;
const {
primary = base.primary,
secondary = base.secondary,
info = base.info,
warning = base.warning,
success = base.success,
error = base.error,
tonalOffset = base.tonalOffset,
hoverFactor = base.hoverFactor,
contrastThreshold = base.contrastThreshold,
...other
} = palette;
function getContrastText(background: string) {
const contrastText =
getContrastRatio(background, dark.text.primary) >= contrastThreshold
? dark.text.maxContrast
: light.text.maxContrast;
// todo, need color framework
return contrastText;
}
function getHoverColor(color: string) {
return emphasize(color, hoverFactor);
}
const getRichColor = ({ color, name }: GetRichColorProps): ThemePaletteColor => {
color = { ...color, name };
if (!color.main) {
throw new Error(`Missing main color for ${name}`);
}
if (!color.border) {
color.border = color.main;
}
if (!color.text) {
color.text = color.main;
}
if (!color.contrastText) {
color.contrastText = getContrastText(color.main);
}
return color as ThemePaletteColor;
};
return merge(
{
...base,
primary: getRichColor({ color: primary, name: 'primary' }),
secondary: getRichColor({ color: secondary, name: 'secondary' }),
info: getRichColor({ color: info, name: 'info' }),
error: getRichColor({ color: error, name: 'error' }),
success: getRichColor({ color: success, name: 'success' }),
warning: getRichColor({ color: warning, name: 'warning' }),
getContrastText,
getHoverColor,
},
other
);
}
interface GetRichColorProps {
color: Partial<ThemePaletteColor>;
name: string;
}

View File

@@ -0,0 +1,49 @@
import { ThemePalette } from './createPalette';
/** @beta */
export interface ThemeShadows {
z1: string;
z2: string;
z3: string;
}
function createDarkShadow(...px: number[]) {
const shadowKeyUmbraOpacity = 0.2;
const shadowKeyPenumbraOpacity = 0.14;
const shadowAmbientShadowOpacity = 0.12;
return [
`${px[0]}px ${px[1]}px ${px[2]}px ${px[3]}px rgba(0,0,0,${shadowKeyUmbraOpacity})`,
`${px[4]}px ${px[5]}px ${px[6]}px ${px[7]}px rgba(0,0,0,${shadowKeyPenumbraOpacity})`,
`${px[8]}px ${px[9]}px ${px[10]}px ${px[11]}px rgba(0,0,0,${shadowAmbientShadowOpacity})`,
].join(',');
}
function createLightShadow(...px: number[]) {
const shadowKeyUmbraOpacity = 0.2;
const shadowKeyPenumbraOpacity = 0.14;
const shadowAmbientShadowOpacity = 0.12;
return [
`${px[0]}px ${px[1]}px ${px[2]}px ${px[3]}px rgba(0,0,0,${shadowKeyUmbraOpacity})`,
`${px[4]}px ${px[5]}px ${px[6]}px ${px[7]}px rgba(0,0,0,${shadowKeyPenumbraOpacity})`,
`${px[8]}px ${px[9]}px ${px[10]}px ${px[11]}px rgba(0,0,0,${shadowAmbientShadowOpacity})`,
].join(',');
}
/** @alpha */
export function createShadows(palette: ThemePalette): ThemeShadows {
if (palette.mode === 'dark') {
return {
z1: createDarkShadow(0, 2, 1, -1, 0, 1, 1, 0, 0, 1, 3, 0),
z2: createDarkShadow(0, 3, 1, -2, 0, 2, 2, 0, 0, 1, 5, 0),
z3: createDarkShadow(0, 3, 3, -2, 0, 3, 4, 0, 0, 1, 8, 0),
};
}
return {
z1: createLightShadow(0, 2, 1, -1, 0, 1, 1, 0, 0, 1, 3, 0),
z2: createLightShadow(0, 3, 1, -2, 0, 2, 2, 0, 0, 1, 5, 0),
z3: createLightShadow(0, 3, 3, -2, 0, 3, 4, 0, 0, 1, 8, 0),
};
}

View File

@@ -0,0 +1,22 @@
/** @beta */
export interface ThemeShape {
borderRadius: (amount?: number) => string;
}
/** @internal */
export interface ThemeShapeInput {
borderRadius?: number;
}
export function createShape(options: ThemeShapeInput): ThemeShape {
const baseBorderRadius = options.borderRadius ?? 2;
const borderRadius = (amount?: number) => {
const value = (amount ?? 1) * baseBorderRadius;
return `${value}px`;
};
return {
borderRadius,
};
}

View File

@@ -0,0 +1,13 @@
import { createSpacing } from './createSpacing';
describe('createSpacing', () => {
it('Spacing function should handle 0-4 arguments', () => {
const spacing = createSpacing();
expect(spacing()).toBe('8px');
expect(spacing(1)).toBe('8px');
expect(spacing(2)).toBe('16px');
expect(spacing(1, 2)).toBe('8px 16px');
expect(spacing(1, 2, 3)).toBe('8px 16px 24px');
expect(spacing(1, 2, 3, 4)).toBe('8px 16px 24px 32px');
});
});

View File

@@ -0,0 +1,68 @@
// Code based on Material UI
// The MIT License (MIT)
// Copyright (c) 2014 Call-Em-All
/** @internal */
export type ThemeSpacingOptions = {
gridSize?: number;
};
/** @internal */
export type ThemeSpacingArgument = number | string;
/**
* @beta
* The different signatures imply different meaning for their arguments that can't be expressed structurally.
* We express the difference with variable names.
* tslint:disable:unified-signatures */
export interface ThemeSpacing {
(): string;
(value: number): string;
(topBottom: ThemeSpacingArgument, rightLeft: ThemeSpacingArgument): string;
(top: ThemeSpacingArgument, rightLeft: ThemeSpacingArgument, bottom: ThemeSpacingArgument): string;
(
top: ThemeSpacingArgument,
right: ThemeSpacingArgument,
bottom: ThemeSpacingArgument,
left: ThemeSpacingArgument
): string;
}
/** @internal */
export function createSpacing(options: ThemeSpacingOptions = {}): ThemeSpacing {
const { gridSize = 8 } = options;
const transform = (value: ThemeSpacingArgument) => {
if (typeof value === 'string') {
return value;
}
if (process.env.NODE_ENV !== 'production') {
if (typeof value !== 'number') {
console.error(`Expected spacing argument to be a number or a string, got ${value}.`);
}
}
return value * gridSize;
};
const spacing = (...args: Array<number | string>): string => {
if (process.env.NODE_ENV !== 'production') {
if (!(args.length <= 4)) {
console.error(`Too many arguments provided, expected between 0 and 4, got ${args.length}`);
}
}
if (args.length === 0) {
args[0] = 1;
}
return args
.map((argument) => {
const output = transform(argument);
return typeof output === 'number' ? `${output}px` : output;
})
.join(' ');
};
return spacing;
}

View File

@@ -0,0 +1,201 @@
import { createTheme } from './createTheme';
describe('createTheme', () => {
it('create default theme', () => {
const theme = createTheme();
expect(theme).toMatchInlineSnapshot(`
Object {
"breakpoints": Object {
"down": [Function],
"keys": Array [
"xs",
"sm",
"md",
"lg",
"xl",
"xxl",
],
"unit": "px",
"up": [Function],
"values": Object {
"lg": 992,
"md": 769,
"sm": 544,
"xl": 1200,
"xs": 0,
"xxl": 1440,
},
},
"components": Object {
"height": Object {
"lg": 6,
"md": 4,
"sm": 3,
},
"panel": Object {
"headerHeight": 4,
"padding": 1,
},
},
"isDark": true,
"isLight": false,
"name": "Dark",
"palette": Object {
"border0": "#202226",
"border1": "#2c3235",
"border2": "#464c54",
"contrastThreshold": 3,
"error": Object {
"border": "#FF5286",
"contrastText": "#fff",
"main": "#D10E5C",
"name": "error",
"text": "#FF5286",
},
"formComponent": Object {
"background": "#0b0c0e",
"border": "#2c3235",
"disabledBackground": "#141619",
"disabledText": "rgba(255, 255, 255, 0.3)",
"text": "rgba(255, 255, 255, 0.75)",
},
"getContrastText": [Function],
"getHoverColor": [Function],
"hoverFactor": 0.15,
"info": Object {
"border": "#5B93FF",
"contrastText": "#fff",
"main": "#3658E2",
"name": "info",
"text": "#5B93FF",
},
"layer0": "#0b0c0e",
"layer1": "#141619",
"layer2": "#202226",
"mode": "dark",
"primary": Object {
"border": "#5B93FF",
"contrastText": "#fff",
"main": "#3658E2",
"name": "primary",
"text": "#5B93FF",
},
"secondary": Object {
"border": "rgba(255,255,255,0.1)",
"contrastText": "rgba(255, 255, 255, 0.8)",
"main": "rgba(255,255,255,0.1)",
"name": "secondary",
"text": "rgba(255,255,255,0.1)",
},
"success": Object {
"border": "#6CCF8E",
"contrastText": "#fff",
"main": "#13875D",
"name": "success",
"text": "#6CCF8E",
},
"text": Object {
"disabled": "rgba(255, 255, 255, 0.3)",
"link": "#5B93FF",
"maxContrast": "#fff",
"primary": "rgba(255, 255, 255, 0.75)",
"secondary": "rgba(255, 255, 255, 0.50)",
},
"tonalOffset": 0.1,
"warning": Object {
"border": "#eb7b18",
"contrastText": "#000",
"main": "#eb7b18",
"name": "warning",
"text": "#eb7b18",
},
},
"shadows": Object {
"z1": "0px 2px 1px -1px rgba(0,0,0,0.2),0px 1px 1px 0px rgba(0,0,0,0.14),0px 1px 3px 0px rgba(0,0,0,0.12)",
"z2": "0px 3px 1px -2px rgba(0,0,0,0.2),0px 2px 2px 0px rgba(0,0,0,0.14),0px 1px 5px 0px rgba(0,0,0,0.12)",
"z3": "0px 3px 3px -2px rgba(0,0,0,0.2),0px 3px 4px 0px rgba(0,0,0,0.14),0px 1px 8px 0px rgba(0,0,0,0.12)",
},
"shape": Object {
"borderRadius": [Function],
},
"spacing": [Function],
"typography": Object {
"body": Object {
"fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif",
"fontSize": "1rem",
"fontWeight": 400,
"letterSpacing": "0.01071em",
"lineHeight": 1.5,
},
"fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif",
"fontFamilyMonospace": "Menlo, Monaco, Consolas, 'Courier New', monospace",
"fontSize": 14,
"fontWeightBold": 700,
"fontWeightLight": 300,
"fontWeightMedium": 500,
"fontWeightRegular": 400,
"h1": Object {
"fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif",
"fontSize": "2rem",
"fontWeight": 300,
"letterSpacing": "-0.05357em",
"lineHeight": 1.167,
},
"h2": Object {
"fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif",
"fontSize": "1.7142857142857142rem",
"fontWeight": 300,
"letterSpacing": "-0.02083em",
"lineHeight": 1.2,
},
"h3": Object {
"fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif",
"fontSize": "1.5rem",
"fontWeight": 400,
"letterSpacing": "0em",
"lineHeight": 1.167,
},
"h4": Object {
"fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif",
"fontSize": "1.2857142857142858rem",
"fontWeight": 400,
"letterSpacing": "0.01389em",
"lineHeight": 1.235,
},
"h5": Object {
"fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif",
"fontSize": "1.1428571428571428rem",
"fontWeight": 400,
"letterSpacing": "0em",
"lineHeight": 1.334,
},
"h6": Object {
"fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif",
"fontSize": "1rem",
"fontWeight": 500,
"letterSpacing": "0.01071em",
"lineHeight": 1.6,
},
"htmlFontSize": 14,
"pxToRem": [Function],
"size": Object {
"base": "14px",
"lg": "18px",
"md": "14px",
"sm": "12px",
"xs": "10px",
},
},
"zIndex": Object {
"dropdown": 1030,
"modal": 1060,
"modalBackdrop": 1050,
"navbarFixed": 1000,
"sidemenu": 1020,
"tooltip": 1040,
"typeahead": 1030,
},
}
`);
});
});

View File

@@ -0,0 +1,67 @@
import { createBreakpoints, ThemeBreakpoints } from './breakpoints';
import { ThemeComponents, createComponents } from './createComponents';
import { createPalette, ThemePalette, ThemePaletteInput } from './createPalette';
import { createShadows, ThemeShadows } from './createShadows';
import { createShape, ThemeShape, ThemeShapeInput } from './createShape';
import { createSpacing, ThemeSpacingOptions, ThemeSpacing } from './createSpacing';
import { createTypography, ThemeTypography, ThemeTypographyInput } from './createTypography';
import { ThemeZIndices, zIndex } from './zIndex';
/** @beta */
export interface GrafanaThemeV2 {
name: string;
isDark: boolean;
isLight: boolean;
palette: ThemePalette;
breakpoints: ThemeBreakpoints;
spacing: ThemeSpacing;
shape: ThemeShape;
components: ThemeComponents;
typography: ThemeTypography;
zIndex: ThemeZIndices;
shadows: ThemeShadows;
}
/** @internal */
export interface NewThemeOptions {
name?: string;
palette?: ThemePaletteInput;
spacing?: ThemeSpacingOptions;
shape?: ThemeShapeInput;
typography?: ThemeTypographyInput;
}
/** @internal */
export function createTheme(options: NewThemeOptions = {}): GrafanaThemeV2 {
const {
name = 'Dark',
palette: paletteInput = {},
spacing: spacingInput = {},
shape: shapeInput = {},
typography: typographyInput = {},
} = options;
const palette = createPalette(paletteInput);
const breakpoints = createBreakpoints();
const spacing = createSpacing(spacingInput);
const shape = createShape(shapeInput);
const components = createComponents();
const typography = createTypography(palette, typographyInput);
const shadows = createShadows(palette);
return {
name,
isDark: palette.mode === 'dark',
isLight: palette.mode === 'light',
palette,
breakpoints,
spacing,
shape,
components,
typography,
shadows,
zIndex: {
...zIndex,
},
};
}

View File

@@ -0,0 +1,145 @@
// Code based on Material UI
// The MIT License (MIT)
// Copyright (c) 2014 Call-Em-All
import { ThemePalette } from './createPalette';
/** @beta */
export interface ThemeTypography {
fontFamily: string;
fontFamilyMonospace: string;
fontSize: number;
fontWeightLight: number;
fontWeightRegular: number;
fontWeightMedium: number;
fontWeightBold: number;
// The font-size on the html element.
htmlFontSize?: number;
h1: ThemeTypographyVariant;
h2: ThemeTypographyVariant;
h3: ThemeTypographyVariant;
h4: ThemeTypographyVariant;
h5: ThemeTypographyVariant;
h6: ThemeTypographyVariant;
body: ThemeTypographyVariant;
/** from legacy old theme */
size: {
base: string;
xs: string;
sm: string;
md: string;
lg: string;
};
pxToRem: (px: number) => string;
}
export interface ThemeTypographyVariant {
fontSize: string;
fontWeight: number;
lineHeight: number;
fontFamily: string;
letterSpacing?: string;
}
export interface ThemeTypographyInput {
fontFamily?: string;
fontFamilyMonospace?: string;
fontSize?: number;
fontWeightLight?: number;
fontWeightRegular?: number;
fontWeightMedium?: number;
fontWeightBold?: number;
// hat's the font-size on the html element.
// 16px is the default font-size used by browsers.
htmlFontSize?: number;
}
const defaultFontFamily = '"Roboto", "Helvetica", "Arial", sans-serif';
const defaultFontFamilyMonospace = "Menlo, Monaco, Consolas, 'Courier New', monospace";
export function createTypography(palette: ThemePalette, typographyInput: ThemeTypographyInput = {}): ThemeTypography {
const {
fontFamily = defaultFontFamily,
fontFamilyMonospace = defaultFontFamilyMonospace,
// The default font size of the Material Specification.
fontSize = 14, // px
fontWeightLight = 300,
fontWeightRegular = 400,
fontWeightMedium = 500,
fontWeightBold = 700,
// Tell Grafana-UI what's the font-size on the html element.
// 16px is the default font-size used by browsers.
htmlFontSize = 14,
} = typographyInput;
if (process.env.NODE_ENV !== 'production') {
if (typeof fontSize !== 'number') {
console.error('Grafana-UI: `fontSize` is required to be a number.');
}
if (typeof htmlFontSize !== 'number') {
console.error('Grafana-UI: `htmlFontSize` is required to be a number.');
}
}
const coef = fontSize / 14;
const pxToRem = (size: number) => `${(size / htmlFontSize) * coef}rem`;
const buildVariant = (
fontWeight: number,
size: number,
lineHeight: number,
letterSpacing: number,
casing?: object
): ThemeTypographyVariant => ({
fontFamily,
fontWeight,
fontSize: pxToRem(size),
// Unitless following https://meyerweb.com/eric/thoughts/2006/02/08/unitless-line-heights/
lineHeight,
// The letter spacing was designed for the Roboto font-family. Using the same letter-spacing
// across font-families can cause issues with the kerning.
...(fontFamily === defaultFontFamily ? { letterSpacing: `${round(letterSpacing / size)}em` } : {}),
...casing,
});
const variants = {
h1: buildVariant(fontWeightLight, 28, 1.167, -1.5),
h2: buildVariant(fontWeightLight, 24, 1.2, -0.5),
h3: buildVariant(fontWeightRegular, 21, 1.167, 0),
h4: buildVariant(fontWeightRegular, 18, 1.235, 0.25),
h5: buildVariant(fontWeightRegular, 16, 1.334, 0),
h6: buildVariant(fontWeightMedium, 14, 1.6, 0.15),
body: buildVariant(fontWeightRegular, 14, 1.5, 0.15),
};
const size = {
base: '14px',
xs: '10px',
sm: '12px',
md: '14px',
lg: '18px',
};
return {
htmlFontSize,
pxToRem,
fontFamily,
fontFamilyMonospace,
fontSize,
fontWeightLight,
fontWeightRegular,
fontWeightMedium,
fontWeightBold,
size,
...variants,
};
}
function round(value: number) {
return Math.round(value * 1e5) / 1e5;
}

View File

@@ -0,0 +1,2 @@
export { createTheme, GrafanaThemeV2 } from './createTheme';
export { ThemePaletteColor } from './types';

View File

@@ -0,0 +1,20 @@
/** @alpha */
export interface ThemePaletteColor {
/** color intent (primary, secondary, info, error, etc) */
name: string;
/** Main color */
main: string;
/** Used for text */
text: string;
/** Used for text */
border: string;
/** Used subtly colored backgrounds */
transparent: string;
/** Text color for text ontop of main */
contrastText: string;
}
/** @internal */
export type DeepPartial<T> = {
[P in keyof T]?: DeepPartial<T[P]>;
};

View File

@@ -0,0 +1,14 @@
// We need to centralize the zIndex definitions as they work
// like global values in the browser.
export const zIndex = {
navbarFixed: 1000,
sidemenu: 1020,
dropdown: 1030,
typeahead: 1030,
tooltip: 1040,
modalBackdrop: 1050,
modal: 1060,
};
/** @beta */
export type ThemeZIndices = typeof zIndex;

View File

@@ -1,3 +1,5 @@
import { GrafanaThemeV2 } from '../themes/createTheme';
export enum GrafanaThemeType {
Light = 'light',
Dark = 'dark',
@@ -111,6 +113,7 @@ export interface GrafanaThemeCommons {
}
export interface GrafanaTheme extends GrafanaThemeCommons {
v2: GrafanaThemeV2;
type: GrafanaThemeType;
isDark: boolean;
isLight: boolean;

View File

@@ -2,40 +2,39 @@
import { create } from '@storybook/theming/create';
import lightTheme from '../src/themes/light';
import darkTheme from '../src/themes/dark';
import ThemeCommons from '../src/themes/default';
import { GrafanaTheme } from '@grafana/data';
const createTheme = (theme: GrafanaTheme) => {
return create({
base: theme.name.includes('Light') ? 'light' : 'dark',
colorPrimary: theme.palette.brandPrimary,
colorSecondary: theme.palette.brandPrimary,
colorPrimary: theme.v2.palette.primary.main,
colorSecondary: theme.v2.palette.secondary.main,
// UI
appBg: theme.colors.bodyBg,
appContentBg: theme.colors.bodyBg,
appBorderColor: theme.colors.pageHeaderBorder,
appBorderRadius: 4,
appBg: theme.v2.palette.layer0,
appContentBg: theme.v2.palette.layer1,
appBorderColor: theme.v2.palette.border1,
appBorderRadius: theme.v2.shape.borderRadius(1),
// Typography
fontBase: ThemeCommons.typography.fontFamily.sansSerif,
fontCode: ThemeCommons.typography.fontFamily.monospace,
fontBase: theme.v2.typography.fontFamily,
fontCode: theme.v2.typography.fontFamilyMonospace,
// Text colors
textColor: theme.colors.text,
textInverseColor: 'rgba(255,255,255,0.9)',
textColor: theme.v2.palette.text.primary,
textInverseColor: theme.v2.palette.primary.contrastText,
// Toolbar default and active colors
barTextColor: theme.colors.formInputBorderActive,
barSelectedColor: theme.palette.brandPrimary,
barBg: theme.colors.pageHeaderBg,
barTextColor: theme.v2.palette.primary.text,
barSelectedColor: theme.v2.palette.getHoverColor(theme.v2.palette.primary.text),
barBg: theme.v2.palette.layer1,
// Form colors
inputBg: theme.colors.formInputBg,
inputBorder: theme.colors.formInputBorder,
inputTextColor: theme.colors.formInputText,
inputBorderRadius: 4,
inputBg: theme.v2.palette.formComponent.background,
inputBorder: theme.v2.palette.formComponent.border,
inputTextColor: theme.v2.palette.formComponent.text,
inputBorderRadius: theme.v2.shape.borderRadius(1),
brandTitle: 'Grafana UI',
brandUrl: './',

View File

@@ -42,7 +42,7 @@ export interface RadioButtonGroupProps<T> {
disabled?: boolean;
disabledOptions?: T[];
options: Array<SelectableValue<T>>;
onChange?: (value?: T) => void;
onChange?: (value: T) => void;
size?: RadioButtonSize;
fullWidth?: boolean;
className?: string;

View File

@@ -10,29 +10,31 @@ export const getFocusStyle = (theme: GrafanaTheme) => css`
`;
export const sharedInputStyle = (theme: GrafanaTheme, invalid = false) => {
const colors = theme.colors;
const borderColor = invalid ? theme.palette.redBase : colors.formInputBorder;
const palette = theme.v2.palette;
const borderColor = invalid ? palette.error.border : palette.formComponent.border;
const background = palette.formComponent.background;
const textColor = palette.text.primary;
return css`
background-color: ${colors.formInputBg};
background-color: ${background};
line-height: ${theme.typography.lineHeight.md};
font-size: ${theme.typography.size.md};
color: ${colors.formInputText};
color: ${textColor};
border: 1px solid ${borderColor};
padding: 0 ${theme.spacing.sm} 0 ${theme.spacing.sm};
padding: ${theme.v2.spacing(0, 1, 0, 1)};
&:-webkit-autofill,
&:-webkit-autofill:hover {
/* Welcome to 2005. This is a HACK to get rid od Chromes default autofill styling */
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0), inset 0 0 0 100px ${colors.formInputBg}!important;
-webkit-text-fill-color: ${colors.formInputText} !important;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0), inset 0 0 0 100px ${background}!important;
-webkit-text-fill-color: ${textColor} !important;
}
&:-webkit-autofill:focus {
/* Welcome to 2005. This is a HACK to get rid od Chromes default autofill styling */
box-shadow: 0 0 0 2px ${theme.colors.bodyBg}, 0 0 0px 4px ${theme.colors.formFocusOutline},
inset 0 0 0 1px rgba(255, 255, 255, 0), inset 0 0 0 100px ${colors.formInputBg}!important;
-webkit-text-fill-color: ${colors.formInputText} !important;
inset 0 0 0 1px rgba(255, 255, 255, 0), inset 0 0 0 100px ${background}!important;
-webkit-text-fill-color: ${textColor} !important;
}
&:hover {
@@ -44,12 +46,12 @@ export const sharedInputStyle = (theme: GrafanaTheme, invalid = false) => {
}
&:disabled {
background-color: ${colors.formInputBgDisabled};
color: ${colors.formInputDisabledText};
background-color: ${palette.formComponent.disabledBackground};
color: ${palette.text.disabled};
}
&::placeholder {
color: ${colors.formInputPlaceholderText};
color: ${palette.text.disabled};
opacity: 1;
}
`;

View File

@@ -30,9 +30,9 @@ interface StyleDeps {
}
export const getInputStyles = stylesFactory(({ theme, invalid = false, width }: StyleDeps) => {
const { palette, colors } = theme;
const borderRadius = theme.border.radius.sm;
const height = theme.spacing.formInputHeight;
const theme2 = theme.v2;
const borderRadius = theme2.shape.borderRadius(1);
const height = theme.v2.components.height.md;
const prefixSuffixStaticWidth = '28px';
const prefixSuffix = css`
@@ -48,7 +48,7 @@ export const getInputStyles = stylesFactory(({ theme, invalid = false, width }:
height: 100%;
/* Min width specified for prefix/suffix classes used outside React component*/
min-width: ${prefixSuffixStaticWidth};
color: ${theme.colors.textWeak};
color: ${theme2.palette.text.secondary};
`;
return {
@@ -57,14 +57,14 @@ export const getInputStyles = stylesFactory(({ theme, invalid = false, width }:
css`
label: input-wrapper;
display: flex;
width: ${width ? `${8 * width}px` : '100%'};
height: ${height}px;
width: ${width ? `${theme2.spacing(width)}` : '100%'};
height: ${theme2.spacing(height)};
border-radius: ${borderRadius};
&:hover {
> .prefix,
.suffix,
.input {
border-color: ${invalid ? palette.redBase : colors.formInputBorder};
border-color: ${invalid ? theme2.palette.error.border : theme2.palette.primary.border};
}
// only show number buttons on hover
@@ -147,8 +147,8 @@ export const getInputStyles = stylesFactory(({ theme, invalid = false, width }:
`
),
inputDisabled: css`
background-color: ${colors.formInputBgDisabled};
color: ${colors.formInputDisabledText};
background-color: ${theme2.palette.formComponent.disabledBackground};
color: ${theme2.palette.text.disabled};
`,
addon: css`
label: input-addon;
@@ -185,8 +185,8 @@ export const getInputStyles = stylesFactory(({ theme, invalid = false, width }:
prefixSuffix,
css`
label: input-prefix;
padding-left: ${theme.spacing.sm};
padding-right: ${theme.spacing.xs};
padding-left: ${theme2.spacing(1)};
padding-right: ${theme2.spacing(0.5)};
border-right: none;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
@@ -196,8 +196,8 @@ export const getInputStyles = stylesFactory(({ theme, invalid = false, width }:
prefixSuffix,
css`
label: input-suffix;
padding-right: ${theme.spacing.sm};
padding-left: ${theme.spacing.xs};
padding-left: ${theme2.spacing(1)};
padding-right: ${theme2.spacing(0.5)};
margin-bottom: -2px;
border-left: none;
border-top-left-radius: 0;
@@ -207,7 +207,7 @@ export const getInputStyles = stylesFactory(({ theme, invalid = false, width }:
),
loadingIndicator: css`
& + * {
margin-left: ${theme.spacing.xs};
margin-left: ${theme2.spacing(0.5)};
}
`,
};

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { Colors } from './Colors';
export default {
title: 'Docs Overview/ThemeColors',
title: 'Docs Overview/Theme',
component: Colors,
decorators: [],
parameters: {

View File

@@ -0,0 +1,18 @@
import React from 'react';
import { NewThemeDemo as NewThemeDemoComponent } from './NewThemeDemo';
export default {
title: 'Docs Overview/Theme',
component: NewThemeDemoComponent,
decorators: [],
parameters: {
options: {
showPanel: false,
},
docs: {},
},
};
export const NewThemeDemo = () => {
return <NewThemeDemoComponent />;
};

View File

@@ -0,0 +1,248 @@
import React, { FC, useState } from 'react';
import { css, cx } from '@emotion/css';
import { useTheme } from '../../themes/ThemeContext';
import { Icon } from '../Icon/Icon';
import { HorizontalGroup } from '../Layout/Layout';
import { GrafanaThemeV2, ThemePaletteColor } from '@grafana/data';
import { CollapsableSection } from '../Collapse/CollapsableSection';
import { Field } from '../Forms/Field';
import { Input } from '../Input/Input';
import { RadioButtonGroup } from '../Forms/RadioButtonGroup/RadioButtonGroup';
import { Switch } from '../Switch/Switch';
import { Button, ButtonVariant } from '../Button';
interface DemoBoxProps {
bg?: string;
border?: string;
textColor?: string;
}
const DemoBox: FC<DemoBoxProps> = ({ bg, border, children }) => {
const style = cx(
css`
padding: 16px;
background: ${bg ?? 'inherit'};
width: 100%;
`,
border
? css`
border: 1px solid ${border};
`
: null
);
return <div className={style}>{children}</div>;
};
const DemoText: FC<{ color?: string; bold?: boolean; size?: number }> = ({ color, bold, size, children }) => {
const style = css`
padding: 4px;
color: ${color ?? 'inherit'};
font-weight: ${bold ? 500 : 400};
font-size: ${size ?? 14}px;
`;
return <div className={style}>{children}</div>;
};
export const NewThemeDemo = () => {
const [radioValue, setRadioValue] = useState('v');
const [boolValue, setBoolValue] = useState(false);
const oldTheme = useTheme();
const t = oldTheme.v2;
const variants: ButtonVariant[] = ['primary', 'secondary', 'destructive', 'link'];
const richColors = [
t.palette.primary,
t.palette.secondary,
t.palette.success,
t.palette.error,
t.palette.warning,
t.palette.info,
];
const radioOptions = [
{ value: 'h', label: 'Horizontal' },
{ value: 'v', label: 'Vertical' },
{ value: 'a', label: 'Auto' },
];
return (
<div
className={css`
width: 100%;
color: ${t.palette.text.primary};
`}
>
<DemoBox bg={t.palette.layer0}>
<CollapsableSection label="Layers" isOpen={true}>
<DemoText>t.palette.layer0</DemoText>
<DemoBox bg={t.palette.layer1} border={t.palette.border0}>
<DemoText>t.palette.layer1 is the main & preferred content </DemoText>
<DemoBox bg={t.palette.layer2} border={t.palette.border0}>
<DemoText>t.palette.layer2 and t.palette.border.layer1</DemoText>
</DemoBox>
</DemoBox>
</CollapsableSection>
<CollapsableSection label="Text colors" isOpen={true}>
<HorizontalGroup>
<DemoBox>
<TextColors t={t} />
</DemoBox>
<DemoBox bg={t.palette.layer1}>
<TextColors t={t} />
</DemoBox>
<DemoBox bg={t.palette.layer2}>
<TextColors t={t} />
</DemoBox>
</HorizontalGroup>
</CollapsableSection>
<CollapsableSection label="Rich colors" isOpen={true}>
<DemoBox bg={t.palette.layer1}>
<table className={colorsTableStyle}>
<thead>
<tr>
<td>name</td>
<td>main</td>
<td>border & text</td>
</tr>
</thead>
<tbody>
{richColors.map((color) => (
<RichColorDemo key={color.name} color={color} theme={t} />
))}
</tbody>
</table>
</DemoBox>
</CollapsableSection>
<CollapsableSection label="Forms" isOpen={true}>
<DemoBox bg={t.palette.layer1}>
<Field label="Input label" description="Field description">
<Input placeholder="Placeholder" />
</Field>
<Field label="Input disabled" disabled>
<Input placeholder="Placeholder" value="Disabled value" />
</Field>
<Field label="Radio label">
<RadioButtonGroup options={radioOptions} value={radioValue} onChange={setRadioValue} />
</Field>
<HorizontalGroup>
<Field label="Switch">
<Switch value={boolValue} onChange={(e) => setBoolValue(e.currentTarget.checked)} />
</Field>
<Field label="Switch true">
<Switch value={true} />
</Field>
<Field label="Switch false disabled">
<Switch value={false} disabled />
</Field>
</HorizontalGroup>
</DemoBox>
</CollapsableSection>
<CollapsableSection label="Shadows" isOpen={true}>
<DemoBox bg={t.palette.layer1}>
<HorizontalGroup>
<ShadowDemo name="Z1" shadow={t.shadows.z1} />
<ShadowDemo name="Z2" shadow={t.shadows.z2} />
<ShadowDemo name="Z3" shadow={t.shadows.z3} />
</HorizontalGroup>
</DemoBox>
</CollapsableSection>
<CollapsableSection label="Buttons" isOpen={true}>
<DemoBox bg={t.palette.layer1}>
<HorizontalGroup>
{variants.map((variant) => (
<Button variant={variant} key={variant}>
{variant}
</Button>
))}
<Button variant="primary" disabled>
Disabled primary
</Button>
</HorizontalGroup>
</DemoBox>
</CollapsableSection>
</DemoBox>
</div>
);
};
interface RichColorDemoProps {
theme: GrafanaThemeV2;
color: ThemePaletteColor;
}
export function RichColorDemo({ theme, color }: RichColorDemoProps) {
return (
<tr>
<td>{color.name}</td>
<td>
<div
className={css`
background: ${color.main};
border-radius: ${theme.shape.borderRadius()};
color: ${color.contrastText};
padding: 8px;
font-weight: 500;
&:hover {
background: ${theme.palette.getHoverColor(color.main)};
}
`}
>
{color.main}
</div>
</td>
<td>
<div
className={css`
border: 1px solid ${color.border};
color: ${color.text};
border-radius: 4px;
padding: 8px;
&:hover {
color: ${color.text};
}
`}
>
{color.text}
</div>
</td>
</tr>
);
}
const colorsTableStyle = css`
text-align: center;
td {
padding: 8px;
text-align: center;
}
`;
export function TextColors({ t }: { t: GrafanaThemeV2 }) {
return (
<>
<DemoText color={t.palette.text.primary}>
text.primary <Icon name="trash-alt" />
</DemoText>
<DemoText color={t.palette.text.secondary}>
text.secondary <Icon name="trash-alt" />
</DemoText>
<DemoText color={t.palette.text.disabled}>
text.disabled <Icon name="trash-alt" />
</DemoText>
<DemoText color={t.palette.primary.text}>
primary.text <Icon name="trash-alt" />
</DemoText>
</>
);
}
export function ShadowDemo({ name, shadow }: { name: string; shadow: string }) {
const style = css({
padding: '16px',
boxShadow: shadow,
});
return <div className={style}>{name}</div>;
}

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { ThemeColors } from './ThemeColors';
export default {
title: 'Docs Overview/ThemeColors',
title: 'Docs Overview/Theme',
component: ThemeColors,
decorators: [],
parameters: {
@@ -13,6 +13,6 @@ export default {
},
};
export const ThemeColorsDemo = () => {
export const OldThemeDemo = () => {
return <ThemeColors />;
};

View File

@@ -27,7 +27,10 @@ interface ClickPluginProps extends PlotPluginProps {
children: (api: ClickPluginAPI) => React.ReactElement | null;
}
// Exposes API for Graph click interactions
/**
* @alpha
* Exposes API for Graph click interactions
*/
export const ClickPlugin: React.FC<ClickPluginProps> = ({ id, onClick, children }) => {
const pluginId = `ClickPlugin:${id}`;

View File

@@ -17,6 +17,7 @@ let ThemeContextMock: React.Context<GrafanaTheme> | null = null;
export const memoizedStyleCreators = new WeakMap();
// Use Grafana Dark theme by default
/** @public */
export const ThemeContext = React.createContext(getTheme(GrafanaThemeType.Dark));
ThemeContext.displayName = 'ThemeContext';

View File

@@ -1,5 +1,5 @@
import defaultTheme, { commonColorsPalette } from './default';
import { GrafanaThemeType, GrafanaTheme } from '@grafana/data';
import { GrafanaThemeType, GrafanaTheme, createTheme } from '@grafana/data';
const basicColors = {
...commonColorsPalette,
@@ -130,6 +130,7 @@ const darkTheme: GrafanaTheme = {
shadows: {
listItem: 'none',
},
v2: createTheme({ palette: { mode: 'dark' } }),
};
export default darkTheme;

View File

@@ -1,5 +1,5 @@
import defaultTheme, { commonColorsPalette } from './default';
import { GrafanaThemeType, GrafanaTheme } from '@grafana/data';
import { GrafanaThemeType, GrafanaTheme, createTheme } from '@grafana/data';
const basicColors = {
...commonColorsPalette,
@@ -131,6 +131,7 @@ const lightTheme: GrafanaTheme = {
shadows: {
listItem: 'none',
},
v2: createTheme({ palette: { mode: 'light' } }),
};
export default lightTheme;

View File

@@ -1,6 +1,9 @@
import { GrafanaThemeType } from '@grafana/data';
type VariantDescriptor = { [key in GrafanaThemeType]: string | number };
/**
* @deprecated
*/
export type VariantDescriptor = { [key in GrafanaThemeType]: string | number };
/**
* @deprecated use theme.isLight ? or theme.isDark instead

View File

@@ -1,7 +1,8 @@
import memoizeOne from 'memoize-one';
// import { KeyValue } from '@grafana/data';
/**
* @public
* @deprecated use useStyles hook
* Creates memoized version of styles creator
* @param stylesCreator function accepting dependencies based on which styles are created
*/

View File

@@ -29,7 +29,7 @@ if [ ! -d "$REPORT_PATH" ]; then
fi
WARNINGS_COUNT="$(find "$REPORT_PATH" -type f -name \*.log -print0 | xargs -0 grep -o "Warning: " | wc -l | xargs)"
WARNINGS_COUNT_LIMIT=1055
WARNINGS_COUNT_LIMIT=1061
if [ "$WARNINGS_COUNT" -gt $WARNINGS_COUNT_LIMIT ]; then
echo -e "API Extractor warnings/errors $WARNINGS_COUNT exceeded $WARNINGS_COUNT_LIMIT so failing build.\n"