Skip to content

Commit

Permalink
feat: <ThemeProvider> (#1526)
Browse files Browse the repository at this point in the history
  • Loading branch information
SergeyRoyt authored Nov 5, 2023
1 parent bf7c1e9 commit cb4a9cf
Show file tree
Hide file tree
Showing 17 changed files with 842 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .storybook/preview.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import "monday-ui-style/dist/index.min.css";
import { ComponentNameDecorator, RelatedComponentsDecorator } from "../src/storybook";
import {
AnchorListItem,
AlphaWarning,
ComponentRules,
DocFooter,
Frame,
Expand Down Expand Up @@ -44,6 +45,7 @@ addParameters({
li: AnchorListItem,
a: Link,
p: Paragraph,
AlphaWarning,
Tip,
Link,
ComponentName: ComponentNameDecorator,
Expand Down
73 changes: 73 additions & 0 deletions src/components/ThemeProvider/ThemeProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import cx from "classnames";
import React, { FC, ReactElement, useEffect, useMemo, useState } from "react";
import { SystemTheme, Theme, ThemeColor } from "./ThemeProviderConstants";
import { generateRandomAlphaString, generateThemeCssOverride, shouldGenerateTheme } from "./ThemeProviderUtils";
import { withStaticProps } from "../../types";

export interface ThemeProviderProps {
/**
* The theme to apply, consists of name - name of css class that will be added to the children, which should be unique - and the object of colors overrides for each system theme.
*/
theme?: Theme;
/**
* The children to render with the theme
*/
children: ReactElement;
/**
* String which adds up to theme name selector to make it more specific (in case if theme.name is colliding with some other class name)
*/
themeClassSpecifier?: string;
}

const ThemeProvider: FC<ThemeProviderProps> & {
systemThemes?: typeof SystemTheme;
colors?: typeof ThemeColor;
} = ({ theme, children, themeClassSpecifier: customThemeClassSpecifier }) => {
const [stylesLoaded, setStylesLoaded] = useState(false);
const themeClassSpecifier = useMemo(
() => customThemeClassSpecifier || generateRandomAlphaString(),
[customThemeClassSpecifier]
);

useEffect(() => {
if (!shouldGenerateTheme(theme)) {
return;
}
if (document.getElementById(theme.name)) {
setStylesLoaded(true);
return;
}

const styleElement = document.createElement("style");
styleElement.type = "text/css";
styleElement.id = theme.name;
const themeCssOverride = generateThemeCssOverride(theme, themeClassSpecifier);

try {
styleElement.appendChild(document.createTextNode(themeCssOverride));
document.head.appendChild(styleElement);
setStylesLoaded(true);
} catch (error) {
console.error("vibe ThemeProvider: error inserting theme-generated css - ", error);
}

return () => {
document.head.removeChild(styleElement);
};
}, [themeClassSpecifier, theme]);

if (!stylesLoaded && shouldGenerateTheme(theme)) {
// Waiting for styles to load before children render
return null;
}

// Pass the theme name as a class to the children - to scope the effect of the theme
return React.cloneElement(children, {
className: cx(theme?.name, themeClassSpecifier, children?.props?.className)
});
};

export default withStaticProps(ThemeProvider, {
systemThemes: SystemTheme,
colors: ThemeColor
});
59 changes: 59 additions & 0 deletions src/components/ThemeProvider/ThemeProviderConstants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* System themes: dark, light, black, hacker
*/
export enum SystemTheme {
LIGHT = "light",
DARK = "dark",
BLACK = "black",
HACKER = "hacker"
}

/**
* Colors which are eligible for theming
*/
export enum ThemeColor {
primaryColor = "primary-color",
primaryHoverColor = "primary-hover-color",
primarySelectedColor = "primary-selected-color",
primarySelectedHoverColor = "primary-selected-hover-color",
primarySelectedOnSecondaryColor = "primary-selected-on-secondary-color",
textColorOnPrimary = "text-color-on-primary",
brandColor = "brand-color",
brandHoverColor = "brand-hover-color",
brandSelectedColor = "brand-selected-color",
brandSelectedHoverColor = "brand-selected-hover-color",
textColorOnBrand = "text-color-on-brand"
}

export type Theme = {
/**
* The name of the theme - name of css class that will be added to the children - should be unique
*/
name: string;
colors: SystemThemeColorMap;
};

type SystemThemeColorMap = {
[key in SystemTheme]?: ThemeColorTokenValueMap;
};

export type ThemeColorTokenValueMap = ThemeColorTokenValue | ThemeCustomClassValue;

export type ThemeColorTokenValue = {
[key in ThemeColor]?: string;
};

type ThemeCustomClassValue = {
[key: string]: ThemeColorTokenValue | ThemeCustomClassValue;
};

export const SystemThemeClassMap: SystemThemeClassMapType = {
[SystemTheme.LIGHT]: "light-app-theme",
[SystemTheme.DARK]: "dark-app-theme",
[SystemTheme.BLACK]: "black-app-theme",
[SystemTheme.HACKER]: "hacker_theme-app-theme"
};

type SystemThemeClassMapType = {
[key in SystemTheme]: string;
};
56 changes: 56 additions & 0 deletions src/components/ThemeProvider/ThemeProviderUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Theme, ThemeColorTokenValueMap, SystemTheme, SystemThemeClassMap } from "./ThemeProviderConstants";

const generateCss = (object: ThemeColorTokenValueMap, stack: string, parentSelector: string) => {
for (const key of Object.keys(object)) {
if (typeof object[key as keyof ThemeColorTokenValueMap] === "string") {
stack += `--${key}: ${object[key as keyof ThemeColorTokenValueMap]};`;
}
}

if (stack !== "") {
stack = parentSelector + " {" + stack + "}";
}

for (const key of Object.keys(object)) {
if (typeof object[key as keyof ThemeColorTokenValueMap] === "object") {
const selector = `${parentSelector} .${key}`;
stack +=
"\n" + generateCss(object[key as keyof ThemeColorTokenValueMap] as ThemeColorTokenValueMap, "", selector);
}
}

return stack;
};

export const shouldGenerateTheme = (theme: Theme) => {
return !!theme?.colors && !!theme?.name;
};

export const generateThemeCssOverride = (theme: Theme, themeClassSpecifier: string) => {
if (!shouldGenerateTheme(theme)) {
return null;
}

let css = "";
for (const systemTheme of Object.keys(theme.colors) as SystemTheme[]) {
css +=
generateCss(
theme.colors[systemTheme],
"",
`.${SystemThemeClassMap[systemTheme]} .${themeClassSpecifier}.${theme.name}`
) + "\n";
}

return css;
};

export const generateRandomAlphaString = (length = 6) => {
let result = "";
const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";

for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * characters.length));
}

return result;
};
165 changes: 165 additions & 0 deletions src/components/ThemeProvider/__stories__/ThemeProvider.stories.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import ThemeProvider from "../ThemeProvider";
import { ArgsTable, Story, Canvas, Meta } from "@storybook/addon-docs";
import { createStoryMetaSettingsDecorator } from "../../../storybook";
import {
ThemeProviderCustomClassTemplate,
ThemeProviderFoldedThemingTemplate,
ThemeProviderNegativeExampleTemplate,
ThemeProviderPositiveExampleTemplate,
ThemeProviderProductThemingTemplate,
ThemeProviderTemplateOverview,
ThemeProviderThemingScopeTemplate
} from "./ThemeProvider.stories";
import { ColorsEligibleForTheming } from "./colors-eligible-for-theming";

export const metaSettings = createStoryMetaSettingsDecorator({
component: ThemeProvider,
enumPropNamesArray: [], // List enum props here
iconPropNamesArray: [], // List props that are typed as icons here
actionPropsArray: [] // List the component's actions here
});

<Meta
title="Components/ThemeProvider [beta]"
component={ThemeProvider}
argTypes={metaSettings.argTypes}
decorators={metaSettings.decorators}
/>

# ThemeProvider

<AlphaWarning />

- [Overview](#overview)
- [Props](#props)
- [Usage](#usage)
- [Variants](#variants)
- [Do’s and don’ts](#dos-and-donts)
- [Feedback](#feedback)

## Overview

This component helps to customize the colors of library's components by overriding a specific css variables with a values provided within theme object.
There are 2 levels of theming: **system theme** and **product theme**.
System theme is a one of a 4 predefined themes: <code>light</code>(<code>.light-app-theme</code>), <code>dark</code>(<code>.dark-app-theme</code>), <code>black</code>(<code>.black-app-theme</code>) and <code>hacker</code>(<code>.hacker_theme-app-theme</code>).
Product theme is a custom theme that can be provided by a user, there you can specify the values of specific color tokens for each of the system themes.

<Canvas>
<Story
name="Overview"
args={{
theme: {
name: "overview-theme",
colors: {
[ThemeProvider.systemThemes.LIGHT]: {
[ThemeProvider.colors.primaryColor]: "green",
[ThemeProvider.colors.primaryHoverColor]: "darkgreen"
},
[ThemeProvider.systemThemes.DARK]: {
[ThemeProvider.colors.primaryColor]: "salmon",
[ThemeProvider.colors.primaryHoverColor]: "darksalmon"
},
[ThemeProvider.systemThemes.BLACK]: {
[ThemeProvider.colors.primaryColor]: "slateblue",
[ThemeProvider.colors.primaryHoverColor]: "darkslateblue"
},
[ThemeProvider.systemThemes.HACKER]: {
[ThemeProvider.colors.primaryColor]: "purple",
[ThemeProvider.colors.primaryHoverColor]: "darkmagenta"
}
}
}
}}
>
{ThemeProviderTemplateOverview.bind({})}
</Story>
</Canvas>

## Props

<ArgsTable story="Overview" />

## Usage

<UsageGuidelines
guidelines={[
<>
Control themes in your application by setting theme classes (e.g. <code>.light-app-theme</code>) on your{" "}
<code>body</code> and render everything else inside it
</>,
<>
In most common case ThemeProvider should be rendered only once on the root level of the application - below the{" "}
<code>body</code>
</>,
<>
ThemeProvider is populating theme name <code>className</code> to it's child, so don't put{" "}
<code>{"\<Fragment\>"}</code> (<code>{"\<\>"}</code>) inside - it's not accepting <code>className</code> prop
</>
]}
/>

<Tip title="Dev tip">
Use <code>ThemeProvider.systemThemes</code> and <code>ThemeProvider.colors</code> enums to unleash the power of
auto-completion
</Tip>

## Variants

There are four system themes <code>light</code>, <code>dark</code>, <code>black</code> and <code>hacker</code> and in each you can redefine the values of the following color tokens:

### Colors eligible for theming

<Story name="Colors eligible for theming">
<ColorsEligibleForTheming />
</Story>

## Use cases and examples

### Theming scope

Only components wrapped with ThemeProvider will be affected by the theme.

<Canvas>
<Story name="Theming scope">{ThemeProviderThemingScopeTemplate.bind({})}</Story>
</Canvas>

### Folded theming

If component is wrapped with multiple ThemeProviders, the most nested one will override the values of the outer one, but if the nested ThemeProvider doesn't provide a value for a specific color token, the outer ThemeProvider will be used.

<Canvas>
<Story name="Folded theming">{ThemeProviderFoldedThemingTemplate.bind({})}</Story>
</Canvas>

### Product theming

These are theme-definitions, which are used in monday.com products.

<Canvas>
<Story name="Product theming">{ThemeProviderProductThemingTemplate.bind({})}</Story>
</Canvas>

### Custom class selector

If you need to apply some of the tokens overrides only on elements under specific class you can declare theme like that:

<Canvas>
<Story name="Custom class selector">{ThemeProviderCustomClassTemplate.bind({})}</Story>
</Canvas>

## Do’s and Don’ts

<ComponentRules
rules={[
{
positive: {
component: [<ThemeProviderPositiveExampleTemplate />],
description: "Pay attention and override all semantically close tokens, if needed."
},
negative: {
component: [<ThemeProviderNegativeExampleTemplate />],
description: "Don’t override only specific tokens to avoid inconsistent appearance"
}
}
]}
/>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.productTheming {
&Container {
height: 200px;
}
&Dropdown {
width: 250px;
}
}
Loading

0 comments on commit cb4a9cf

Please sign in to comment.