diff --git a/.gitignore b/.gitignore index 80122c579..e1d925764 100644 --- a/.gitignore +++ b/.gitignore @@ -9,5 +9,5 @@ yarn-error.log .idea/* types index.d.ts -computed-colors-*.json -computed-icons.ts \ No newline at end of file +computed-icons.ts +computed-colors.json diff --git a/package.json b/package.json index c20a0d282..c0182d3fa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ably/ui", - "version": "14.6.9", + "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..c4a38e7e9 100644 --- a/scripts/compute-colors.ts +++ b/scripts/compute-colors.ts @@ -1,72 +1,83 @@ 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 { colors, 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 joinedVariants = variants.join("|"); +const joinedPrefixes = prefixes.join("|"); +const joinedColors = colors.join("|"); +const regex = new RegExp( + `themeColor\\("((${joinedVariants}${joinedPrefixes})-(${joinedColors})-(000|[1-9]00|1[0-3]00))"\\)`, + "g", +); + +const findStringInFiles = (dir: string) => { + const results: string[] = []; + + const readDirectory = (dir: string) => { + let files: string[]; + try { + files = fs.readdirSync(dir); + } catch (error) { + console.error(`Error reading directory ${dir}:`, error); + return; + } + + files.forEach((file) => { + const filePath = path.join(dir, file); + let stat; + try { + stat = fs.statSync(filePath); + } catch (error) { + console.error(`Error accessing ${filePath}:`, error); + return; + } - const colors = {} as ComputedColors; + if (stat.isDirectory()) { + readDirectory(filePath); + } else if (filePath.endsWith(".tsx")) { + let content = ""; + try { + content = fs.readFileSync(filePath, "utf-8"); + const matches = [...content.matchAll(regex)].map((match) => match[1]); - 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]}`, - }; + if (matches.length > 0) { + results.push(...matches); } - }), - ), - ), - ); + } catch (error) { + console.error(`Error reading file ${filePath}:`, error); + return; + } + } + }); + }; - 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 { + const outputDir = path.dirname(outputPath); + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); } + fs.writeFileSync(outputPath, JSON.stringify(flippedMatches)); + console.log( + `🎨 Tailwind theme classes have been computed and written to JSON files.`, + ); +} catch (error) { + console.error(`Error persisting computed colors:`, error); } - -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 ccedcb1d8..b8691a2d4 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}
@@ -95,7 +97,7 @@ const PricingCards = ({ {delimiterColumn(index)}
) : null}

{title.content}

@@ -130,7 +132,7 @@ const PricingCards = ({ ) : null}

(descriptionsRef.current[index] = el)}> @@ -142,18 +144,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 : (
-
+
)}
@@ -171,7 +177,7 @@ const PricingCards = ({ {sections.map(({ title, items, listItemColors, cta }) => (

{title}

@@ -180,12 +186,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} @@ -196,14 +202,16 @@ const PricingCards = ({ {listItemColors ? ( ) : null}
{item}
@@ -215,16 +223,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..e0c5951f7 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: "light", + 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..33ddd1e08 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 { colorRoles } from "./types"; import Icon from "../../Icon"; +import useTheming from "../../hooks/useTheming"; export default { title: "CSS/Colors", @@ -47,7 +47,7 @@ const varToValues = (color: string) => { export const NeutralColors = { render: () => (
- {colorSet([...colorNames.neutral])} + {colorSet([...colorRoles.neutral])}
), parameters: { @@ -62,7 +62,7 @@ export const NeutralColors = { export const OrangeColors = { render: () => (
- {colorSet([...colorNames.orange])} + {colorSet([...colorRoles.orange])}
), parameters: { @@ -77,7 +77,7 @@ export const OrangeColors = { export const SecondaryColors = { render: () => (
- {colorSet([...colorNames.secondary])} + {colorSet([...colorRoles.secondary])}
), parameters: { @@ -91,7 +91,7 @@ export const SecondaryColors = { export const GUIColors = { render: () => ( -
{colorSet([...colorNames.gui])}
+
{colorSet([...colorRoles.gui])}
), parameters: { docs: { @@ -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/types.ts b/src/core/styles/colors/types.ts index 142bfc559..6214a5a06 100644 --- a/src/core/styles/colors/types.ts +++ b/src/core/styles/colors/types.ts @@ -19,6 +19,18 @@ export const prefixes = ["text", "bg", "from", "to", "border"] as const; type ColorClassPrefixes = (typeof prefixes)[number]; +export const colors = [ + "neutral", + "orange", + "blue", + "yellow", + "green", + "violet", + "pink", +] as const; + +export type ColorClassColorGroups = (typeof colors)[number]; + export type Theme = "light" | "dark"; export type ColorClass = @@ -164,24 +176,19 @@ const aliasedColors = [ "transparent", ] as const; -export const colorNames = { +export const colorRoles = { neutral: neutralColors, orange: orangeColors, secondary: secondaryColors, gui: guiColors, }; -export const numericalColors = [ - neutralColors, - orangeColors, - yellowColors, - greenColors, - blueColors, - violetColors, - pinkColors, -]; - -export type ComputedColors = Record< - ColorClass, - Partial> ->; +export const colorGroupLengths = { + neutral: neutralColors.length, + orange: orangeColors.length, + blue: blueColors.length, + yellow: yellowColors.length, + green: greenColors.length, + violet: violetColors.length, + pink: pinkColors.length, +}; diff --git a/src/core/styles/colors/utils.ts b/src/core/styles/colors/utils.ts index 050440a03..ad4bff7ca 100644 --- a/src/core/styles/colors/utils.ts +++ b/src/core/styles/colors/utils.ts @@ -1,26 +1,28 @@ -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 { ColorClass, ColorClassColorGroups, colorGroupLengths } 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; +export const invertTailwindClassVariant = (className: string): ColorClass => { + const splitMatch = className.split("-"); + if (splitMatch.length < 3) { + throw new Error("Invalid TW class format"); } - return color; + 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; + if (isNaN(numericalVariant)) { + throw new Error(`Invalid variant value in TW class: ${className}`); + } + + const flippedVariant = + colorGroupLengths[color as ColorClassColorGroups] - + numericalVariant - + (color === "neutral" ? 1 : -1); + const flippedMatch = `${property}-${flippedVariant}00`; + + return flippedMatch as ColorClass; };