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

feat(theme): add option to use css variables #4264

Merged
merged 10 commits into from
Mar 13, 2025
Merged
6 changes: 6 additions & 0 deletions .changeset/brave-clouds-sneeze.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@twilio-paste/core": minor
"@twilio-paste/design-tokens": minor
---

[Design Tokens] added a new build script to generate a CSS file that applies variables for individual themes using the body[data-theme] attribute.
Copy link
Collaborator

Choose a reason for hiding this comment

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

question: does the gulp task require a changeset?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It didn't kick one off but I think it would be good to include a changeset for this, something we should be communicating I feel.

6 changes: 6 additions & 0 deletions .changeset/pretty-weeks-smash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@twilio-paste/core": minor
"@twilio-paste/theme": minor
---

[Theme] Added the property `useCssVariables` which allows the color values to be pulled from CSS variables instead of static values
31 changes: 31 additions & 0 deletions packages/paste-design-tokens/gulpfile.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import * as gulp from "gulp";
import gulpif from "gulp-if";
import gulpRename from "gulp-rename";
import terser from "gulp-terser";
import gulpTheo from "gulp-theo";
import * as theo from "theo";
import { obj } from "through2";

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

gulp.task("tokens:data-theme", () =>
gulp
.src(paths.tokensEntry)
.pipe(
gulpTheo({
transform: { type: "web" },
format: { type: "custom-properties.css" },
}),
)
.on("error", (err: string) => {
throw new Error(err);
})
.pipe(
obj(function (file: any, _: any, cb: any) {
if (file.isBuffer()) {
const themeName: string[] | null = file.path.match(/themes\/(.*)\//)?.[1];
if (themeName) {
const code = file.contents.toString();
file.contents = Buffer.from(code.replace(":root", `body[data-theme="${themeName}"]`));
}
}
cb(null, file);
}),
)
.pipe(gulpRename({ extname: ".data-theme.css", basename: "tokens" }))
.pipe(gulp.dest(paths.dist)),
);

gulp.task("tokens:scss", () =>
gulp
.src(paths.tokensEntry)
Expand Down Expand Up @@ -293,6 +323,7 @@ gulp.task(
"tokens:sketchpalette",
"tokens:generic:js",
"tokens:generic:d:ts",
"tokens:data-theme",
),
);

Expand Down
4 changes: 3 additions & 1 deletion packages/paste-design-tokens/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@
"gulp": "^4.0.2",
"gulp-cli": "^2.2.0",
"gulp-if": "^3.0.0",
"gulp-rename": "^2.0.0",
"gulp-terser": "^2.0.1",
"gulp-theo": "2.0.1",
"lodash": "4.17.21"
"lodash": "4.17.21",
"through2": "^4.0.2"
}
}
28 changes: 28 additions & 0 deletions packages/paste-theme/__tests__/__snapshots__/themes.spec.ts.snap
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`CSS Theme should match the snapshot 1`] = `
Array [
"backgroundColors",
"borderColors",
"borderWidths",
"breakpoints",
"colorSchemes",
"dataVisualization",
"fonts",
"fontSizes",
"fontWeights",
"heights",
"iconSizes",
"lineHeights",
"maxHeights",
"maxWidths",
"minHeights",
"minWidths",
"radii",
"shadows",
"sizes",
"space",
"textColors",
"widths",
"zIndices",
]
`;

exports[`Default theme should match the snapshot 1`] = `
Array [
"backgroundColors",
Expand Down
12 changes: 12 additions & 0 deletions packages/paste-theme/__tests__/themes.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { DefaultTheme, SendGridTheme } from "../src";
import { CSSVariablesTheme } from "../src/themes/css-variables";

describe("Default theme", () => {
it("should match the snapshot", () => {
Expand All @@ -10,3 +11,14 @@ describe("Sendgrid theme", () => {
expect(Object.keys(SendGridTheme).sort((a, b) => a.localeCompare(b))).toMatchSnapshot();
});
});
describe("CSS Theme", () => {
it("should match the snapshot", () => {
expect(Object.keys(CSSVariablesTheme).sort((a, b) => a.localeCompare(b))).toMatchSnapshot();
});
it("should transform values correctly", () => {
expect(CSSVariablesTheme.space).toEqual(expect.objectContaining({ space10: "var(--space-10)" }));
expect(CSSVariablesTheme.textColors).toEqual(
expect.objectContaining({ colorTextBrand: "var(--color-text-brand)" }),
);
});
});
12 changes: 8 additions & 4 deletions packages/paste-theme/src/generateThemeFromTokens.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { sizings as fallbackSizings } from "@twilio-paste/design-tokens/dist/themes/twilio/tokens.es6";

import type {
BackgroundColorsKeys,
BorderColorsKeys,
Expand All @@ -19,7 +21,7 @@ import type {
} from "./types/GenericThemeShape";
import { remToPx } from "./utils/remToPx";

interface GenerateThemeFromTokensArgs {
export interface GenerateThemeFromTokensArgs {
backgroundColors: Partial<{ [key in BackgroundColorsKeys]: any }>;
borderColors: Partial<{ [key in BorderColorsKeys]: any }>;
borderWidths: Partial<{ [key in BorderWidthsKeys]: any }>;
Expand Down Expand Up @@ -56,11 +58,13 @@ export const generateThemeFromTokens = ({
textColors,
zIndices,
}: GenerateThemeFromTokensArgs): GenericThemeShape => {
// breakpoints need rm not CSS variables so need to use a fallback for the default sizings
const sizingsForBreakpoints = sizings.size0.includes("var") ? fallbackSizings : sizings;
// default breakpoints
const breakpoints = [
remToPx(sizings.size40, "string"),
remToPx(sizings.size100, "string"),
remToPx(sizings.size120, "string"),
remToPx(sizingsForBreakpoints.size40, "string"),
remToPx(sizingsForBreakpoints.size100, "string"),
remToPx(sizingsForBreakpoints.size120, "string"),
];

return {
Expand Down
31 changes: 27 additions & 4 deletions packages/paste-theme/src/themeProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,13 @@ import { pasteBaseStyles } from "./styles/base";
import { pasteFonts } from "./styles/fonts";
import { pasteGlobalStyles } from "./styles/global";
import { DarkTheme, DefaultTheme, EvergreenTheme, SendGridTheme, TwilioDarkTheme, TwilioTheme } from "./themes";
import { CSSVariablesTheme } from "./themes/css-variables";
import { getThemeFromHash } from "./utils/getThemeFromHash";

export const StyledBase = styled.div(pasteBaseStyles);

const CSSVariablesThemeKey = "CSSVariables";

const useThemeOverwriteHook = (): string | undefined => {
const [overwriteTheme, setOverwriteTheme] = React.useState(getThemeFromHash());

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

// eslint-disable-next-line @typescript-eslint/ban-types
function getProviderThemeProps(theme: ThemeVariants, customBreakpoints?: string[]): {} {
function getProviderThemeProps(theme: ThemeVariants | typeof CSSVariablesThemeKey, customBreakpoints?: string[]): {} {
switch (theme) {
case ThemeVariants.TWILIO:
return {
Expand All @@ -64,6 +67,11 @@ function getProviderThemeProps(theme: ThemeVariants, customBreakpoints?: string[
...EvergreenTheme,
breakpoints: customBreakpoints || EvergreenTheme.breakpoints,
};
case CSSVariablesThemeKey:
return {
...CSSVariablesTheme,
breakpoints: customBreakpoints || CSSVariablesTheme.breakpoints,
};
case ThemeVariants.DEFAULT:
default:
return {
Expand All @@ -73,20 +81,32 @@ function getProviderThemeProps(theme: ThemeVariants, customBreakpoints?: string[
}
}

export interface ThemeProviderProps {
interface BaseThemeProviderProps {
customBreakpoints?: string[];
theme?: ThemeVariants;
disableAnimations?: boolean;
cacheProviderProps?: CreateCacheOptions;
style?: React.CSSProperties;
}

interface ThemeProviderThemeProps extends BaseThemeProviderProps {
theme?: ThemeVariants;
useCSSVariables?: never;
}

interface ThemeProviderCSSVariablesProps extends BaseThemeProviderProps {
theme?: never;
useCSSVariables?: boolean;
}

export type ThemeProviderProps = ThemeProviderThemeProps | ThemeProviderCSSVariablesProps;

const ThemeProvider: React.FunctionComponent<React.PropsWithChildren<ThemeProviderProps>> = ({
customBreakpoints,
theme = ThemeVariants.DEFAULT,
disableAnimations = false,
// https://emotion.sh/docs/@emotion/cache#options
cacheProviderProps,
useCSSVariables,
...props
}) => {
const [cache] = React.useState(cacheProviderProps ? createCache(cacheProviderProps) : null);
Expand All @@ -98,7 +118,10 @@ const ThemeProvider: React.FunctionComponent<React.PropsWithChildren<ThemeProvid
}, [disableAnimations, prefersReducedMotion]);
const overwriteTheme = useThemeOverwriteHook();

const providerThemeProps = getProviderThemeProps(overwriteTheme || theme, customBreakpoints);
const providerThemeProps = getProviderThemeProps(
overwriteTheme || (useCSSVariables ? CSSVariablesThemeKey : theme),
customBreakpoints,
);

if (cache) {
return (
Expand Down
59 changes: 59 additions & 0 deletions packages/paste-theme/src/themes/css-variables/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import {
backgroundColors,
borderColors,
borderWidths,
boxShadows,
colorSchemes,
colors,
dataVisualization,
fontSizes,
fontWeights,
fonts,
lineHeights,
radii,
sizings,
spacings,
textColors,
zIndices,
} from "@twilio-paste/design-tokens/dist/tokens.es6";

import { GenerateThemeFromTokensArgs, generateThemeFromTokens } from "../../generateThemeFromTokens";

const convertToCSSVariables = (
tokens: GenerateThemeFromTokensArgs | object,
): Partial<Record<keyof GenerateThemeFromTokensArgs, string | object>> => {
const cssVariables: Partial<Record<keyof GenerateThemeFromTokensArgs, string | object>> = {};
Copy link
Collaborator

Choose a reason for hiding this comment

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

Typescript ftw 🙌


for (const [key, value] of Object.entries(tokens) as Array<[keyof GenerateThemeFromTokensArgs, string | object]>) {
if (typeof value === "object") {
cssVariables[key] = convertToCSSVariables(value);
} else {
// Convert the key to a CSS variable name colorBagroundPrimary -> --color-background-primary size10 -> --size-10
const cssVariableName = `--${key.replace(/([A-Z])/g, "-$1").replace(/(\d+)/g, "-$1").toLowerCase()}`;
cssVariables[key] = `var(${cssVariableName})`;
}
}

return cssVariables;
};

export const CSSVariablesTheme = generateThemeFromTokens(
convertToCSSVariables({
backgroundColors,
borderColors,
borderWidths,
radii,
fonts,
fontSizes,
fontWeights,
lineHeights,
boxShadows,
sizings,
spacings,
textColors,
zIndices,
dataVisualization,
colors,
colorSchemes,
}) as GenerateThemeFromTokensArgs,
);
56 changes: 56 additions & 0 deletions packages/paste-theme/stories/cssVariables.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Box } from "@twilio-paste/box";
import { Button } from "@twilio-paste/button";
import { Combobox } from "@twilio-paste/combobox";
import "@twilio-paste/design-tokens/dist/themes/dark/tokens.data-theme.css";
import "@twilio-paste/design-tokens/dist/themes/evergreen/tokens.data-theme.css";
import "@twilio-paste/design-tokens/dist/themes/twilio-dark/tokens.data-theme.css";
import "@twilio-paste/design-tokens/dist/themes/twilio/tokens.data-theme.css";
import "@twilio-paste/design-tokens/dist/tokens.custom-properties.css";
import { Input } from "@twilio-paste/input";
import { Paragraph } from "@twilio-paste/paragraph";
import { Stack } from "@twilio-paste/stack";
import { TextArea } from "@twilio-paste/textarea";
import * as React from "react";

import { ThemeProvider } from "../src/themeProvider";

// eslint-disable-next-line import/no-default-export
export default {
title: "Theme/ThemeProvider/CSSVariables",
component: ThemeProvider,
};

export const StylingThemeProviderElement = (): React.ReactNode => (
<ThemeProvider useCSSVariables={true}>
<Paragraph>
This theme provider uses CSS variables. You can change the theme using this combobox to switch with theme
variables should be applied. Note Storbook also ahs a theme provider wrapping the components in the view. You will
see the body color not get applied
</Paragraph>
<Combobox
labelText="Select a theme"
items={["twilio", "twilio-dark", "dark", "evergreen"]}
onSelectedItemChange={(value) => {
document.body.setAttribute("data-theme", value.selectedItem);
}}
/>
<Box marginTop="space60">
<Paragraph>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore
magna aliqua. Nisi porta lorem mollis aliquam ut porttitor leo. Hendrerit gravida rutrum quisque non. A arcu
cursus vitae congue mauris rhoncus aenean vel elit. Tortor dignissim convallis aenean et tortor at risus.
Vestibulum lorem sed risus ultricies. Tempor nec feugiat nisl pretium fusce id. Morbi tempus iaculis urna id
volutpat lacus laoreet non curabitur. In ante metus dictum at. Sit amet risus nullam eget felis eget nunc
lobortis.
</Paragraph>
<Stack orientation="vertical" spacing="space50">
<Button variant="primary" onClick={() => {}}>
Click me
</Button>
<Input aria-label="Search" placeholder="Search options..." type="text" />

<TextArea aria-label="Feedback" value="Lorem ipsum dolor sit amet, consectetur adipiscing elit" />
</Stack>
</Box>
</ThemeProvider>
);
9 changes: 5 additions & 4 deletions packages/paste-website/src/pages/theme/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,11 @@ import {Theme} from '@twilio-paste/theme';

### Props

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

### Choosing a theme

Expand Down
Loading
Loading