diff --git a/.changeset/brave-clouds-sneeze.md b/.changeset/brave-clouds-sneeze.md new file mode 100644 index 0000000000..a77d867560 --- /dev/null +++ b/.changeset/brave-clouds-sneeze.md @@ -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. diff --git a/.changeset/pretty-weeks-smash.md b/.changeset/pretty-weeks-smash.md new file mode 100644 index 0000000000..ca17f894ba --- /dev/null +++ b/.changeset/pretty-weeks-smash.md @@ -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 diff --git a/packages/paste-design-tokens/gulpfile.ts b/packages/paste-design-tokens/gulpfile.ts index 84115142b5..183962195c 100644 --- a/packages/paste-design-tokens/gulpfile.ts +++ b/packages/paste-design-tokens/gulpfile.ts @@ -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"; @@ -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) @@ -293,6 +323,7 @@ gulp.task( "tokens:sketchpalette", "tokens:generic:js", "tokens:generic:d:ts", + "tokens:data-theme", ), ); diff --git a/packages/paste-design-tokens/package.json b/packages/paste-design-tokens/package.json index 47eccb477f..b2ac2cb75b 100644 --- a/packages/paste-design-tokens/package.json +++ b/packages/paste-design-tokens/package.json @@ -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" } } diff --git a/packages/paste-theme/__tests__/__snapshots__/themes.spec.ts.snap b/packages/paste-theme/__tests__/__snapshots__/themes.spec.ts.snap index 17787e5d6f..d1567e654b 100644 --- a/packages/paste-theme/__tests__/__snapshots__/themes.spec.ts.snap +++ b/packages/paste-theme/__tests__/__snapshots__/themes.spec.ts.snap @@ -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", diff --git a/packages/paste-theme/__tests__/themes.spec.ts b/packages/paste-theme/__tests__/themes.spec.ts index 180ccb6b7c..a313ec1b7c 100644 --- a/packages/paste-theme/__tests__/themes.spec.ts +++ b/packages/paste-theme/__tests__/themes.spec.ts @@ -1,4 +1,5 @@ import { DefaultTheme, SendGridTheme } from "../src"; +import { CSSVariablesTheme } from "../src/themes/css-variables"; describe("Default theme", () => { it("should match the snapshot", () => { @@ -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)" }), + ); + }); +}); diff --git a/packages/paste-theme/src/generateThemeFromTokens.ts b/packages/paste-theme/src/generateThemeFromTokens.ts index 7b3621f315..342dff0fd1 100644 --- a/packages/paste-theme/src/generateThemeFromTokens.ts +++ b/packages/paste-theme/src/generateThemeFromTokens.ts @@ -1,3 +1,5 @@ +import { sizings as fallbackSizings } from "@twilio-paste/design-tokens/dist/themes/twilio/tokens.es6"; + import type { BackgroundColorsKeys, BorderColorsKeys, @@ -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 }>; @@ -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 { diff --git a/packages/paste-theme/src/themeProvider.tsx b/packages/paste-theme/src/themeProvider.tsx index 16687744eb..6dfb853aeb 100644 --- a/packages/paste-theme/src/themeProvider.tsx +++ b/packages/paste-theme/src/themeProvider.tsx @@ -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()); @@ -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 { @@ -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 { @@ -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> = ({ customBreakpoints, theme = ThemeVariants.DEFAULT, disableAnimations = false, // https://emotion.sh/docs/@emotion/cache#options cacheProviderProps, + useCSSVariables, ...props }) => { const [cache] = React.useState(cacheProviderProps ? createCache(cacheProviderProps) : null); @@ -98,7 +118,10 @@ const ThemeProvider: React.FunctionComponent> => { + const cssVariables: Partial> = {}; + + 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, +); diff --git a/packages/paste-theme/stories/cssVariables.stories.tsx b/packages/paste-theme/stories/cssVariables.stories.tsx new file mode 100644 index 0000000000..cb66f50cb6 --- /dev/null +++ b/packages/paste-theme/stories/cssVariables.stories.tsx @@ -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 => ( + + + 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 + + { + document.body.setAttribute("data-theme", value.selectedItem); + }} + /> + + + 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. + + + + + +