From ebf23c497cb8b7adc103991c9a27134ab3089921 Mon Sep 17 00:00:00 2001 From: Jamie Henson Date: Mon, 14 Oct 2024 15:05:49 +0100 Subject: [PATCH] feat: add useTheming hook, reduce generated TW classes --- .gitignore | 2 +- package.json | 2 +- scripts/compute-colors.ts | 119 +++++++++++----------- src/core/Icon/EncapsulatedIcon.tsx | 13 ++- src/core/Pricing/PricingCards.tsx | 50 +++++---- src/core/ProductTile.tsx | 18 ++-- src/core/Tooltip.tsx | 14 +-- src/core/hooks/useTheming.tsx | 25 +++++ src/core/styles/colors/Colors.stories.tsx | 34 ++++--- src/core/styles/colors/utils.ts | 51 ++++++---- 10 files changed, 189 insertions(+), 139 deletions(-) create mode 100644 src/core/hooks/useTheming.tsx diff --git a/.gitignore b/.gitignore index e6a079703..66d14b66a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,4 @@ yarn-error.log .idea/* types index.d.ts -computed-colors-*.json \ No newline at end of file +computed-colors.json \ No newline at end of file diff --git a/package.json b/package.json index 346431776..55b86df2a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ably/ui", - "version": "14.6.8", + "version": "14.7.0", "description": "Home of the Ably design system library ([design.ably.com](https://design.ably.com)). It provides a showcase, development/test environment and a publishing pipeline for different distributables.", "repository": { "type": "git", diff --git a/scripts/compute-colors.ts b/scripts/compute-colors.ts index 5ad21278c..ae191a81e 100644 --- a/scripts/compute-colors.ts +++ b/scripts/compute-colors.ts @@ -1,72 +1,69 @@ import fs from "fs"; import path from "path"; -import { - numericalColors, - variants, - prefixes, - Theme, - ComputedColors, -} from "../src/core/styles/colors/types"; +import { invertTailwindClassVariant } from "../src/core/styles/colors/utils"; +import { prefixes, variants } from "../src/core/styles/colors/types"; -const computeColors = (base: Theme) => { - if (base !== "dark" && base !== "light") { - throw new Error(`Invalid base theme: ${base}. Expected "dark" or "light".`); - } +const directoryPath = path.join(__dirname, "../src"); +const outputPath = path.join( + __dirname, + "../src/core/styles/colors", + "computed-colors.json", +); - const colors = {} as ComputedColors; +const joinedVariants = variants.join("|"); +const joinedPrefixes = prefixes.join("|"); +const colors = [ + "neutral", + "orange", + "blue", + "yellow", + "green", + "violet", + "pink", +].join("|"); - variants.forEach((variant) => - prefixes.forEach((property) => - numericalColors.forEach((colorSet) => - colorSet.map((color, index) => { - if (base === "dark") { - colors[`${variant}${property}-${colorSet[index]}`] = { - light: `${variant}${property}-${colorSet[colorSet.length - index - 1]}`, - }; - } else if (base === "light") { - colors[`${variant}${property}-${colorSet[index]}`] = { - dark: `${variant}${property}-${colorSet[colorSet.length - index - 1]}`, - }; - } - }), - ), - ), - ); +const findStringInFiles = (dir: string) => { + const results: string[] = []; + + const readDirectory = (dir: string) => { + const files = fs.readdirSync(dir); + + files.forEach((file) => { + const filePath = path.join(dir, file); + const stat = fs.statSync(filePath); + + if (stat.isDirectory()) { + readDirectory(filePath); + } else if (filePath.endsWith(".tsx")) { + const content = fs.readFileSync(filePath, "utf-8"); + const regex = new RegExp( + `themeColor\\("((${joinedVariants}${joinedPrefixes})-(${colors})-(000|[1-9]00|1[0-3]00))"\\)`, + "g", + ); + const matches = [...content.matchAll(regex)].map((match) => match[1]); + + if (matches.length > 0) { + results.push(...matches); + } + } + }); + }; - return colors; + readDirectory(dir); + return Array.from(new Set(results)).sort(); }; -const darkOutputPath = path.join( - __dirname, - "../src/core/styles/colors", - "computed-colors-dark.json", -); -const lightOutputPath = path.join( - __dirname, - "../src/core/styles/colors", - "computed-colors-light.json", +const matches = findStringInFiles(directoryPath); + +const flippedMatches = matches.map((match) => + invertTailwindClassVariant(match), ); -async function writeComputedColors() { - try { - await Promise.all([ - fs.promises.writeFile( - darkOutputPath, - JSON.stringify(computeColors("dark"), null, 2), - "utf-8", - ), - fs.promises.writeFile( - lightOutputPath, - JSON.stringify(computeColors("light"), null, 2), - "utf-8", - ), - ]); - console.log( - `🎨 Tailwind theme classes have been computed and written to JSON files.`, - ); - } catch { - console.error(`Error persisting computed colors.`); - } +try { + fs.writeFileSync(outputPath, JSON.stringify(flippedMatches)); + console.log( + `🎨 Tailwind theme classes have been computed and written to JSON files.`, + ); +} catch { + console.error(`Error persisting computed colors.`); } - -writeComputedColors(); diff --git a/src/core/Icon/EncapsulatedIcon.tsx b/src/core/Icon/EncapsulatedIcon.tsx index c2ae698fc..50e993291 100644 --- a/src/core/Icon/EncapsulatedIcon.tsx +++ b/src/core/Icon/EncapsulatedIcon.tsx @@ -1,7 +1,7 @@ import React from "react"; import Icon, { IconProps } from "../Icon"; -import { determineThemeColor } from "../styles/colors/utils"; -import { ColorClass, Theme } from "../styles/colors/types"; +import useTheming from "../hooks/useTheming"; +import { Theme } from "../styles/colors/types"; type EncapsulatedIconProps = { theme?: Theme; @@ -18,7 +18,10 @@ const EncapsulatedIcon = ({ innerClassName, ...iconProps }: EncapsulatedIconProps) => { - const t = (color: ColorClass) => determineThemeColor("dark", theme, color); + const { themeColor } = useTheming({ + baseTheme: "dark", + theme, + }); const numericalSize = parseInt(size, 10); const numericalIconSize = iconSize ? parseInt(iconSize, 10) @@ -26,11 +29,11 @@ const EncapsulatedIcon = ({ return (
diff --git a/src/core/Pricing/PricingCards.tsx b/src/core/Pricing/PricingCards.tsx index 2c3768f1f..a9ff50d39 100644 --- a/src/core/Pricing/PricingCards.tsx +++ b/src/core/Pricing/PricingCards.tsx @@ -1,12 +1,12 @@ import React, { Fragment, useEffect, useRef, useState } from "react"; import throttle from "lodash.throttle"; import type { PricingDataFeature } from "./types"; -import { determineThemeColor } from "../styles/colors/utils"; import { ColorClass, Theme } from "../styles/colors/types"; import Icon from "../Icon"; import FeaturedLink from "../FeaturedLink"; import { IconName } from "../Icon/types"; import Tooltip from "../Tooltip"; +import useTheming from "../hooks/useTheming"; export type PricingCardsProps = { data: PricingDataFeature[]; @@ -45,8 +45,10 @@ const PricingCards = ({ }; }, []); - // work out a dynamic theme colouring, using dark theme colouring as the base - const t = (color: ColorClass) => determineThemeColor("dark", theme, color); + const { themeColor } = useTheming({ + baseTheme: "dark", + theme, + }); const delimiterColumn = (index: number) => delimiter && index % 2 === 1 ? ( @@ -57,7 +59,7 @@ const PricingCards = ({ ) : null}
@@ -90,7 +92,7 @@ const PricingCards = ({ {delimiterColumn(index)}
{border ? (
) : null}

{title.content}

@@ -122,7 +124,7 @@ const PricingCards = ({ ) : null}

(descriptionsRef.current[index] = el)}> @@ -134,18 +136,20 @@ const PricingCards = ({ className={`flex items-end gap-8 ${delimiter ? "@[520px]:flex-col @[520px]:items-start @[920px]:flex-row @[920px]:items-end" : ""}`} >

{price.amount}

-
+
{price.content}
{cta ? (
) : delimiter ? null : (
-
+
)}
@@ -163,7 +169,7 @@ const PricingCards = ({ {sections.map(({ title, items, listItemColors, cta }) => (

{title}

@@ -172,12 +178,12 @@ const PricingCards = ({ Array.isArray(item) ? (
0 && index % 2 === 0 ? `${t("bg-blue-900")} rounded-md` : ""}`} + className={`flex justify-between gap-16 px-8 -mx-8 ${index === 0 ? "py-8" : "py-4"} ${index > 0 && index % 2 === 0 ? `${themeColor("bg-blue-900")} rounded-md` : ""}`} > {item.map((subItem, subIndex) => ( {subItem} @@ -188,14 +194,16 @@ const PricingCards = ({ {listItemColors ? ( ) : null}
{item}
@@ -207,16 +215,16 @@ const PricingCards = ({
{cta.text}
•••
diff --git a/src/core/ProductTile.tsx b/src/core/ProductTile.tsx index 2c3fb134f..bf7b58801 100644 --- a/src/core/ProductTile.tsx +++ b/src/core/ProductTile.tsx @@ -2,8 +2,7 @@ import React from "react"; import EncapsulatedIcon from "./Icon/EncapsulatedIcon"; import FeaturedLink from "./FeaturedLink"; import { ProductName, products } from "./ProductTile/data"; -import { ColorClass } from "./styles/colors/types"; -import { determineThemeColor } from "./styles/colors/utils"; +import useTheming from "./hooks/useTheming"; type ProductTileProps = { name: ProductName; @@ -20,14 +19,15 @@ const ProductTile = ({ className, onClick, }: ProductTileProps) => { + const { themeColor } = useTheming({ + baseTheme: "dark", + theme: selected ? "light" : "dark", + }); const { icon, label, description, link, unavailable } = products[name] ?? {}; - const t = (color: ColorClass) => - determineThemeColor("dark", selected ? "light" : "dark", color); - return (
@@ -38,12 +38,12 @@ const ProductTile = ({ className={`flex ${unavailable ? "flex-row items-center gap-4" : "flex-col justify-center"} `} >

Ably{" "}

{label}

@@ -63,7 +63,7 @@ const ProductTile = ({

{selected && link ? ( diff --git a/src/core/Tooltip.tsx b/src/core/Tooltip.tsx index 652e91615..ef62c3ee0 100644 --- a/src/core/Tooltip.tsx +++ b/src/core/Tooltip.tsx @@ -11,8 +11,8 @@ import React, { } from "react"; import { createPortal } from "react-dom"; import Icon from "./Icon"; -import { ColorClass, Theme } from "./styles/colors/types"; -import { determineThemeColor } from "./styles/colors/utils"; +import useTheming from "./hooks/useTheming"; +import { Theme } from "./styles/colors/types"; type TooltipProps = { triggerElement?: ReactNode; @@ -38,8 +38,10 @@ const Tooltip = ({ const reference = useRef(null); const floating = useRef(null); const fadeOutTimeoutRef = useRef(null); - - const t = (color: ColorClass) => determineThemeColor("light", theme, color); + const { themeColor } = useTheming({ + baseTheme: "dark", + theme, + }); useEffect(() => { if (open) { @@ -164,7 +166,7 @@ const Tooltip = ({ {triggerElement ?? ( )} @@ -185,7 +187,7 @@ const Tooltip = ({ boxShadow: "4px 4px 15px rgba(0, 0, 0, 0.2)", }} {...tooltipProps} - className={`${t("bg-neutral-1000")} ${t("text-neutral-200")} ui-text-p3 font-medium p-16 ${interactive ? "" : "pointer-events-none"} rounded-lg absolute ${ + className={`${themeColor("bg-neutral-1000")} ${themeColor("text-neutral-200")} ui-text-p3 font-medium p-16 ${interactive ? "" : "pointer-events-none"} rounded-lg absolute ${ tooltipProps?.className ?? "" } ${fadeOut ? "animate-[tooltipExit_0.25s_ease-in-out]" : "animate-[tooltipEntry_0.25s_ease-in-out]"}`} > diff --git a/src/core/hooks/useTheming.tsx b/src/core/hooks/useTheming.tsx new file mode 100644 index 000000000..8c8befaef --- /dev/null +++ b/src/core/hooks/useTheming.tsx @@ -0,0 +1,25 @@ +import { useCallback } from "react"; +import { ColorClass, Theme } from "../styles/colors/types"; +import { invertTailwindClassVariant } from "../styles/colors/utils"; + +type UseThemingProps = { + baseTheme?: Theme; + theme?: Theme; +}; + +const useTheming = ({ + baseTheme = "dark", + theme = "dark", +}: UseThemingProps) => { + const themeColor = useCallback( + (color: ColorClass) => + theme === baseTheme ? color : invertTailwindClassVariant(color), + [baseTheme, theme], + ); + + return { + themeColor, + }; +}; + +export default useTheming; diff --git a/src/core/styles/colors/Colors.stories.tsx b/src/core/styles/colors/Colors.stories.tsx index 3c3edfe41..74610d148 100644 --- a/src/core/styles/colors/Colors.stories.tsx +++ b/src/core/styles/colors/Colors.stories.tsx @@ -1,7 +1,7 @@ import React from "react"; import { colorNames } from "./types"; -import { determineThemeColor } from "./utils"; import Icon from "../../Icon"; +import useTheming from "../../hooks/useTheming"; export default { title: "CSS/Colors", @@ -104,25 +104,29 @@ export const GUIColors = { }; export const DynamicTheming = { - render: () => ( -
-
- {colorSet(["orange-300"], "bg-orange-300")} -
- -
- {colorSet( - ["orange-900"], - determineThemeColor("dark", "light", "bg-orange-300"), - )} + render: () => { + const { themeColor } = useTheming({ + baseTheme: "dark", + theme: "light", + }); + + return ( +
+
+ {colorSet(["orange-300"], "bg-orange-300")} +
+ +
+ {colorSet(["orange-900"], themeColor("bg-orange-300"))} +
-
- ), + ); + }, parameters: { docs: { description: { story: - "We can generate alternatives for a color based on the theme. Example usage: `determineThemeColor('dark', 'light', 'bg-orange-300')` - this takes a base theme of 'dark', a target theme of 'light', and the colour to convert.", + "We can generate alternatives for a color based on the theme. To do this, pull in the `useTheming` hook and access the `themeColor` function - passing in `baseTheme` and `theme` when you call the hook to provide the context for `themeColor. Then, wrap any Tailwind color class in `themeColor` to conditionally generate the alternative color, if the target theme differs from the base theme. Any new classes will be generated and fed into Tailwind at build time.", }, }, }, diff --git a/src/core/styles/colors/utils.ts b/src/core/styles/colors/utils.ts index 050440a03..208ced8b3 100644 --- a/src/core/styles/colors/utils.ts +++ b/src/core/styles/colors/utils.ts @@ -1,26 +1,37 @@ -import { ColorClass, ComputedColors, Theme } from "./types"; - -// If missing, run any build script involving build:colors, i.e. yarn storybook -import computedColorsDark from "./computed-colors-dark.json"; -import computedColorsLight from "./computed-colors-light.json"; +import { + blueColors, + ColorClass, + greenColors, + neutralColors, + orangeColors, + pinkColors, + violetColors, + yellowColors, +} from "./types"; export const convertTailwindClassToVar = (className: string) => className.replace(/(text|bg|from|to)-([a-z0-9-]+)/gi, "var(--color-$2)"); -export const determineThemeColor = ( - baseTheme: Theme, - currentTheme: Theme, - color: ColorClass, -) => { - if (baseTheme === currentTheme) { - return color; - } else if (baseTheme === "light") { - return ( - (computedColorsLight as ComputedColors)[color][currentTheme] || color - ); - } else if (baseTheme === "dark") { - return (computedColorsDark as ComputedColors)[color][currentTheme] || color; - } +const extents: Record = { + neutral: neutralColors.length, + orange: orangeColors.length, + blue: blueColors.length, + yellow: yellowColors.length, + green: greenColors.length, + violet: violetColors.length, + pink: pinkColors.length, +}; + +export const invertTailwindClassVariant = (className: string): ColorClass => { + const splitMatch = className.split("-"); + const color = splitMatch[splitMatch.length - 2]; + const variant = splitMatch[splitMatch.length - 1]; + const property = splitMatch.slice(0, splitMatch.length - 1).join("-"); + + const numericalVariant = Number(variant.slice(0, -2)) ?? 0; + const flippedVariant = + extents[color] - numericalVariant - (color === "neutral" ? 1 : -1); + const flippedMatch = `${property}-${flippedVariant}00`; - return color; + return flippedMatch as ColorClass; };