Skip to content

Commit b5e73f1

Browse files
feat(theme): add option to use css variables (#4264)
* feat(desgn-tokens): build a data-theme css file * feat(css-theme): add the index file for theme * feat(css-theme): add the index file for theme * feat(css-theme): added tests and working * chore(ci): changeset * feat(docs): added prop to theme provider table * chore(ci): lint fix * chore(ci): lint fix * Update packages/paste-theme/src/generateThemeFromTokens.ts Co-authored-by: Nora Krantz <[email protected]> * fix(theme): variable name change --------- Co-authored-by: Nora Krantz <[email protected]>
1 parent 86aa3c1 commit b5e73f1

File tree

12 files changed

+250
-13
lines changed

12 files changed

+250
-13
lines changed

.changeset/brave-clouds-sneeze.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@twilio-paste/core": minor
3+
"@twilio-paste/design-tokens": minor
4+
---
5+
6+
[Design Tokens] added a new build script to generate a CSS file that applies variables for individual themes using the body[data-theme] attribute.

.changeset/pretty-weeks-smash.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@twilio-paste/core": minor
3+
"@twilio-paste/theme": minor
4+
---
5+
6+
[Theme] Added the property `useCssVariables` which allows the color values to be pulled from CSS variables instead of static values

packages/paste-design-tokens/gulpfile.ts

+31
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import * as gulp from "gulp";
22
import gulpif from "gulp-if";
3+
import gulpRename from "gulp-rename";
34
import terser from "gulp-terser";
45
import gulpTheo from "gulp-theo";
56
import * as theo from "theo";
7+
import { obj } from "through2";
68

79
import { commonTokenFormat } from "./formatters/common";
810
import { dTSTokenFormat } from "./formatters/d.ts";
@@ -63,6 +65,34 @@ gulp.task("tokens:css-custom-props", () =>
6365
.pipe(gulp.dest(paths.dist)),
6466
);
6567

68+
gulp.task("tokens:data-theme", () =>
69+
gulp
70+
.src(paths.tokensEntry)
71+
.pipe(
72+
gulpTheo({
73+
transform: { type: "web" },
74+
format: { type: "custom-properties.css" },
75+
}),
76+
)
77+
.on("error", (err: string) => {
78+
throw new Error(err);
79+
})
80+
.pipe(
81+
obj(function (file: any, _: any, cb: any) {
82+
if (file.isBuffer()) {
83+
const themeName: string[] | null = file.path.match(/themes\/(.*)\//)?.[1];
84+
if (themeName) {
85+
const code = file.contents.toString();
86+
file.contents = Buffer.from(code.replace(":root", `body[data-theme="${themeName}"]`));
87+
}
88+
}
89+
cb(null, file);
90+
}),
91+
)
92+
.pipe(gulpRename({ extname: ".data-theme.css", basename: "tokens" }))
93+
.pipe(gulp.dest(paths.dist)),
94+
);
95+
6696
gulp.task("tokens:scss", () =>
6797
gulp
6898
.src(paths.tokensEntry)
@@ -293,6 +323,7 @@ gulp.task(
293323
"tokens:sketchpalette",
294324
"tokens:generic:js",
295325
"tokens:generic:d:ts",
326+
"tokens:data-theme",
296327
),
297328
);
298329

packages/paste-design-tokens/package.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,10 @@
2828
"gulp": "^4.0.2",
2929
"gulp-cli": "^2.2.0",
3030
"gulp-if": "^3.0.0",
31+
"gulp-rename": "^2.0.0",
3132
"gulp-terser": "^2.0.1",
3233
"gulp-theo": "2.0.1",
33-
"lodash": "4.17.21"
34+
"lodash": "4.17.21",
35+
"through2": "^4.0.2"
3436
}
3537
}

packages/paste-theme/__tests__/__snapshots__/themes.spec.ts.snap

+28
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,33 @@
11
// Jest Snapshot v1, https://goo.gl/fbAQLP
22

3+
exports[`CSS Theme should match the snapshot 1`] = `
4+
Array [
5+
"backgroundColors",
6+
"borderColors",
7+
"borderWidths",
8+
"breakpoints",
9+
"colorSchemes",
10+
"dataVisualization",
11+
"fonts",
12+
"fontSizes",
13+
"fontWeights",
14+
"heights",
15+
"iconSizes",
16+
"lineHeights",
17+
"maxHeights",
18+
"maxWidths",
19+
"minHeights",
20+
"minWidths",
21+
"radii",
22+
"shadows",
23+
"sizes",
24+
"space",
25+
"textColors",
26+
"widths",
27+
"zIndices",
28+
]
29+
`;
30+
331
exports[`Default theme should match the snapshot 1`] = `
432
Array [
533
"backgroundColors",

packages/paste-theme/__tests__/themes.spec.ts

+12
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { DefaultTheme, SendGridTheme } from "../src";
2+
import { CSSVariablesTheme } from "../src/themes/css-variables";
23

34
describe("Default theme", () => {
45
it("should match the snapshot", () => {
@@ -10,3 +11,14 @@ describe("Sendgrid theme", () => {
1011
expect(Object.keys(SendGridTheme).sort((a, b) => a.localeCompare(b))).toMatchSnapshot();
1112
});
1213
});
14+
describe("CSS Theme", () => {
15+
it("should match the snapshot", () => {
16+
expect(Object.keys(CSSVariablesTheme).sort((a, b) => a.localeCompare(b))).toMatchSnapshot();
17+
});
18+
it("should transform values correctly", () => {
19+
expect(CSSVariablesTheme.space).toEqual(expect.objectContaining({ space10: "var(--space-10)" }));
20+
expect(CSSVariablesTheme.textColors).toEqual(
21+
expect.objectContaining({ colorTextBrand: "var(--color-text-brand)" }),
22+
);
23+
});
24+
});

packages/paste-theme/src/generateThemeFromTokens.ts

+8-4
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { sizings as fallbackSizings } from "@twilio-paste/design-tokens/dist/themes/twilio/tokens.es6";
2+
13
import type {
24
BackgroundColorsKeys,
35
BorderColorsKeys,
@@ -19,7 +21,7 @@ import type {
1921
} from "./types/GenericThemeShape";
2022
import { remToPx } from "./utils/remToPx";
2123

22-
interface GenerateThemeFromTokensArgs {
24+
export interface GenerateThemeFromTokensArgs {
2325
backgroundColors: Partial<{ [key in BackgroundColorsKeys]: any }>;
2426
borderColors: Partial<{ [key in BorderColorsKeys]: any }>;
2527
borderWidths: Partial<{ [key in BorderWidthsKeys]: any }>;
@@ -56,11 +58,13 @@ export const generateThemeFromTokens = ({
5658
textColors,
5759
zIndices,
5860
}: GenerateThemeFromTokensArgs): GenericThemeShape => {
61+
// breakpoints need rm not CSS variables so need to use a fallback for the default sizings
62+
const sizingsForBreakpoints = sizings.size0.includes("var") ? fallbackSizings : sizings;
5963
// default breakpoints
6064
const breakpoints = [
61-
remToPx(sizings.size40, "string"),
62-
remToPx(sizings.size100, "string"),
63-
remToPx(sizings.size120, "string"),
65+
remToPx(sizingsForBreakpoints.size40, "string"),
66+
remToPx(sizingsForBreakpoints.size100, "string"),
67+
remToPx(sizingsForBreakpoints.size120, "string"),
6468
];
6569

6670
return {

packages/paste-theme/src/themeProvider.tsx

+27-4
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,13 @@ import { pasteBaseStyles } from "./styles/base";
1414
import { pasteFonts } from "./styles/fonts";
1515
import { pasteGlobalStyles } from "./styles/global";
1616
import { DarkTheme, DefaultTheme, EvergreenTheme, SendGridTheme, TwilioDarkTheme, TwilioTheme } from "./themes";
17+
import { CSSVariablesTheme } from "./themes/css-variables";
1718
import { getThemeFromHash } from "./utils/getThemeFromHash";
1819

1920
export const StyledBase = styled.div(pasteBaseStyles);
2021

22+
const CSSVariablesThemeKey = "CSSVariables";
23+
2124
const useThemeOverwriteHook = (): string | undefined => {
2225
const [overwriteTheme, setOverwriteTheme] = React.useState(getThemeFromHash());
2326

@@ -37,7 +40,7 @@ const useThemeOverwriteHook = (): string | undefined => {
3740
};
3841

3942
// eslint-disable-next-line @typescript-eslint/ban-types
40-
function getProviderThemeProps(theme: ThemeVariants, customBreakpoints?: string[]): {} {
43+
function getProviderThemeProps(theme: ThemeVariants | typeof CSSVariablesThemeKey, customBreakpoints?: string[]): {} {
4144
switch (theme) {
4245
case ThemeVariants.TWILIO:
4346
return {
@@ -64,6 +67,11 @@ function getProviderThemeProps(theme: ThemeVariants, customBreakpoints?: string[
6467
...EvergreenTheme,
6568
breakpoints: customBreakpoints || EvergreenTheme.breakpoints,
6669
};
70+
case CSSVariablesThemeKey:
71+
return {
72+
...CSSVariablesTheme,
73+
breakpoints: customBreakpoints || CSSVariablesTheme.breakpoints,
74+
};
6775
case ThemeVariants.DEFAULT:
6876
default:
6977
return {
@@ -73,20 +81,32 @@ function getProviderThemeProps(theme: ThemeVariants, customBreakpoints?: string[
7381
}
7482
}
7583

76-
export interface ThemeProviderProps {
84+
interface BaseThemeProviderProps {
7785
customBreakpoints?: string[];
78-
theme?: ThemeVariants;
7986
disableAnimations?: boolean;
8087
cacheProviderProps?: CreateCacheOptions;
8188
style?: React.CSSProperties;
8289
}
8390

91+
interface ThemeProviderThemeProps extends BaseThemeProviderProps {
92+
theme?: ThemeVariants;
93+
useCSSVariables?: never;
94+
}
95+
96+
interface ThemeProviderCSSVariablesProps extends BaseThemeProviderProps {
97+
theme?: never;
98+
useCSSVariables?: boolean;
99+
}
100+
101+
export type ThemeProviderProps = ThemeProviderThemeProps | ThemeProviderCSSVariablesProps;
102+
84103
const ThemeProvider: React.FunctionComponent<React.PropsWithChildren<ThemeProviderProps>> = ({
85104
customBreakpoints,
86105
theme = ThemeVariants.DEFAULT,
87106
disableAnimations = false,
88107
// https://emotion.sh/docs/@emotion/cache#options
89108
cacheProviderProps,
109+
useCSSVariables,
90110
...props
91111
}) => {
92112
const [cache] = React.useState(cacheProviderProps ? createCache(cacheProviderProps) : null);
@@ -98,7 +118,10 @@ const ThemeProvider: React.FunctionComponent<React.PropsWithChildren<ThemeProvid
98118
}, [disableAnimations, prefersReducedMotion]);
99119
const overwriteTheme = useThemeOverwriteHook();
100120

101-
const providerThemeProps = getProviderThemeProps(overwriteTheme || theme, customBreakpoints);
121+
const providerThemeProps = getProviderThemeProps(
122+
overwriteTheme || (useCSSVariables ? CSSVariablesThemeKey : theme),
123+
customBreakpoints,
124+
);
102125

103126
if (cache) {
104127
return (
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import {
2+
backgroundColors,
3+
borderColors,
4+
borderWidths,
5+
boxShadows,
6+
colorSchemes,
7+
colors,
8+
dataVisualization,
9+
fontSizes,
10+
fontWeights,
11+
fonts,
12+
lineHeights,
13+
radii,
14+
sizings,
15+
spacings,
16+
textColors,
17+
zIndices,
18+
} from "@twilio-paste/design-tokens/dist/tokens.es6";
19+
20+
import { GenerateThemeFromTokensArgs, generateThemeFromTokens } from "../../generateThemeFromTokens";
21+
22+
const convertToCSSVariables = (
23+
tokens: GenerateThemeFromTokensArgs | object,
24+
): Partial<Record<keyof GenerateThemeFromTokensArgs, string | object>> => {
25+
const cssVariables: Partial<Record<keyof GenerateThemeFromTokensArgs, string | object>> = {};
26+
27+
for (const [key, value] of Object.entries(tokens) as Array<[keyof GenerateThemeFromTokensArgs, string | object]>) {
28+
if (typeof value === "object") {
29+
cssVariables[key] = convertToCSSVariables(value);
30+
} else {
31+
// Convert the key to a CSS variable name colorBagroundPrimary -> --color-background-primary size10 -> --size-10
32+
const cssVariableName = `--${key.replace(/([A-Z])/g, "-$1").replace(/(\d+)/g, "-$1").toLowerCase()}`;
33+
cssVariables[key] = `var(${cssVariableName})`;
34+
}
35+
}
36+
37+
return cssVariables;
38+
};
39+
40+
export const CSSVariablesTheme = generateThemeFromTokens(
41+
convertToCSSVariables({
42+
backgroundColors,
43+
borderColors,
44+
borderWidths,
45+
radii,
46+
fonts,
47+
fontSizes,
48+
fontWeights,
49+
lineHeights,
50+
boxShadows,
51+
sizings,
52+
spacings,
53+
textColors,
54+
zIndices,
55+
dataVisualization,
56+
colors,
57+
colorSchemes,
58+
}) as GenerateThemeFromTokensArgs,
59+
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { Box } from "@twilio-paste/box";
2+
import { Button } from "@twilio-paste/button";
3+
import { Combobox } from "@twilio-paste/combobox";
4+
import "@twilio-paste/design-tokens/dist/themes/dark/tokens.data-theme.css";
5+
import "@twilio-paste/design-tokens/dist/themes/evergreen/tokens.data-theme.css";
6+
import "@twilio-paste/design-tokens/dist/themes/twilio-dark/tokens.data-theme.css";
7+
import "@twilio-paste/design-tokens/dist/themes/twilio/tokens.data-theme.css";
8+
import "@twilio-paste/design-tokens/dist/tokens.custom-properties.css";
9+
import { Input } from "@twilio-paste/input";
10+
import { Paragraph } from "@twilio-paste/paragraph";
11+
import { Stack } from "@twilio-paste/stack";
12+
import { TextArea } from "@twilio-paste/textarea";
13+
import * as React from "react";
14+
15+
import { ThemeProvider } from "../src/themeProvider";
16+
17+
// eslint-disable-next-line import/no-default-export
18+
export default {
19+
title: "Theme/ThemeProvider/CSSVariables",
20+
component: ThemeProvider,
21+
};
22+
23+
export const StylingThemeProviderElement = (): React.ReactNode => (
24+
<ThemeProvider useCSSVariables={true}>
25+
<Paragraph>
26+
This theme provider uses CSS variables. You can change the theme using this combobox to switch with theme
27+
variables should be applied. Note Storbook also ahs a theme provider wrapping the components in the view. You will
28+
see the body color not get applied
29+
</Paragraph>
30+
<Combobox
31+
labelText="Select a theme"
32+
items={["twilio", "twilio-dark", "dark", "evergreen"]}
33+
onSelectedItemChange={(value) => {
34+
document.body.setAttribute("data-theme", value.selectedItem);
35+
}}
36+
/>
37+
<Box marginTop="space60">
38+
<Paragraph>
39+
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore
40+
magna aliqua. Nisi porta lorem mollis aliquam ut porttitor leo. Hendrerit gravida rutrum quisque non. A arcu
41+
cursus vitae congue mauris rhoncus aenean vel elit. Tortor dignissim convallis aenean et tortor at risus.
42+
Vestibulum lorem sed risus ultricies. Tempor nec feugiat nisl pretium fusce id. Morbi tempus iaculis urna id
43+
volutpat lacus laoreet non curabitur. In ante metus dictum at. Sit amet risus nullam eget felis eget nunc
44+
lobortis.
45+
</Paragraph>
46+
<Stack orientation="vertical" spacing="space50">
47+
<Button variant="primary" onClick={() => {}}>
48+
Click me
49+
</Button>
50+
<Input aria-label="Search" placeholder="Search options..." type="text" />
51+
52+
<TextArea aria-label="Feedback" value="Lorem ipsum dolor sit amet, consectetur adipiscing elit" />
53+
</Stack>
54+
</Box>
55+
</ThemeProvider>
56+
);

packages/paste-website/src/pages/theme/index.mdx

+5-4
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,11 @@ import {Theme} from '@twilio-paste/theme';
8080

8181
### Props
8282

83-
| Prop | Type | Description | Default |
84-
| ------------------ | -------- | ----------------------------------------------------------------------------------------- | ----------------- |
85-
| theme? | enum | 'default', 'dark', 'twilio', 'twilio-dark' and 'evergreen' | 'default' |
86-
| customBreakpoints? | string[] | An optional array of string values for custom screen sizes in the usual CSS width formats | theme.breakpoints |
83+
| Prop | Type | Description | Default |
84+
|--------------------|----------|-------------------------------------------------------------------------------------------------------------------------------------------|-------------------|
85+
| theme? | enum | 'default', 'dark', 'twilio', 'twilio-dark' and 'evergreen' | 'default' |
86+
| customBreakpoints? | string[] | An optional array of string values for custom screen sizes in the usual CSS width formats | theme.breakpoints |
87+
| useCSSVariables? | boolean | Allows css varibales from generated design tokens to be used instead of absolute values. Useful when using server and static components | false |
8788

8889
### Choosing a theme
8990

0 commit comments

Comments
 (0)