diff --git a/docs/BrandColors.stories.tsx b/docs/BrandColors.stories.tsx index c250fc5..4fd28d3 100644 --- a/docs/BrandColors.stories.tsx +++ b/docs/BrandColors.stories.tsx @@ -1,7 +1,11 @@ import React from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import { brandColor as brandColorJS } from '../src/js'; -import { getCSSVariablesFromStylesheet, useJsonColor } from './utils'; +import { + getCSSVariablesFromStylesheet, + getContrastYIQ, + useJsonColor, +} from './utils'; import { ColorSwatchGroup, ColorSwatch } from './components'; import README from './BrandColors.mdx'; @@ -22,6 +26,7 @@ type Story = StoryObj; export const Figma: Story = { render: () => { const { brandColor } = useJsonColor(); + console.log(brandColor); return ; }, }; @@ -43,6 +48,8 @@ export const CSS: Story = { key={name} color={color} backgroundColor={name} + textBackgroundColor="transparent" + textColor={getContrastYIQ(color, color)} name={name} /> ))} @@ -62,7 +69,13 @@ export const JS: Story = { > {/* Mapping through each brand color and rendering a ColorSwatch component for it */} {Object.entries(brandColorJS).map(([name, color]) => ( - + ))} ), diff --git a/docs/ThemeColors.stories.tsx b/docs/ThemeColors.stories.tsx index edf29b7..a4847be 100644 --- a/docs/ThemeColors.stories.tsx +++ b/docs/ThemeColors.stories.tsx @@ -1,10 +1,14 @@ import React from 'react'; - import { lightTheme as lightThemeJS, darkTheme as darkThemeJS } from '../src'; import brandColor from '../src/figma/brandColors.json'; import { ColorSwatch, ColorSwatchGroup } from './components'; import README from './ThemeColors.mdx'; -import { getCSSVariablesFromStylesheet, useJsonColor } from './utils'; +import { + getCSSVariablesFromStylesheet, + getContrastYIQ, + getJSColors, + useJsonColor, +} from './utils'; export default { title: 'Colors/Theme Colors', @@ -43,9 +47,7 @@ export const FigmaDarkTheme = { > ); @@ -74,6 +76,11 @@ export const CSSLightTheme = { @@ -110,8 +117,11 @@ export const CSSDarkTheme = { name={colorName} backgroundColor={colorName} borderColor="var(--color-border-muted)" - textBackgroundColor="var(--color-background-default)" - textColor="var(--color-text-default)" + textBackgroundColor="transparent" + textColor={getContrastYIQ( + color, + darkThemeJS.colors.background.default, // TODO Use CSS instead of JS object once CSS object is cleaned up + )} /> ), )} @@ -137,62 +147,60 @@ export const CSSDarkTheme = { }; export const JSLightTheme = { - render: () => ( -
- {Object.entries(lightThemeJS.colors).flatMap(([category, colorObj]) => - Object.entries(colorObj).map(([name, color]) => ( + render: () => { + const colors = getJSColors(lightThemeJS.colors); + return ( +
+ {colors.map(({ name, color }) => ( - )), - )} -
- ), + ))} +
+ ); + }, }; export const JSDarkTheme = { - render: () => ( -
+ render: () => { + const colors = getJSColors(darkThemeJS.colors); + return (
- {Object.entries(darkThemeJS.colors).flatMap(([category, colorObj]) => - Object.entries(colorObj).map(([name, color]) => ( - - )), - )} + {colors.map(({ name, color }) => ( + + ))}
-
- ), - parameters: { - backgrounds: { - default: 'dark', - values: [{ name: 'dark', value: darkThemeJS.colors.background.default }], - }, + ); }, }; diff --git a/docs/components/ColorSwatchGroup/ColorSwatchGroup.tsx b/docs/components/ColorSwatchGroup/ColorSwatchGroup.tsx index 2a263d0..ead5079 100644 --- a/docs/components/ColorSwatchGroup/ColorSwatchGroup.tsx +++ b/docs/components/ColorSwatchGroup/ColorSwatchGroup.tsx @@ -1,5 +1,6 @@ -import React, { FunctionComponent } from 'react'; +import React from 'react'; import { Theme } from '../../utils/useJsonColor'; +import { getContrastYIQ } from '../../utils/getContrastYIQ'; import { ColorSwatch } from '..'; interface ColorSwatchGroupProps { @@ -11,84 +12,126 @@ interface ColorSwatchGroupProps { * The color of text background that contains the name of the color defaults to background.default */ textBackgroundColor?: string; - /** - * The border color of the swatch defaults to border.muted - */ - borderColor?: string; - /** - * The color of the text defaults to text.default - */ - textColor?: string; - /** - * The name of the color + /** Hex code value of the theme (light or dark mode) this is used to help determine the text color of each swatch when opacity is involved + * Default is light theme #ffffff */ - name?: string; + theme?: string | undefined; } -export const ColorSwatchGroup: FunctionComponent = ({ +function toCamelCase(str: string) { + // Check if the string contains a dash followed by a number, if so, skip modification + if (/\-\d+%$/.test(str)) { + return str; + } + return str.replace(/-([a-z])/gi, function (g) { + return (g[1] ?? '').toUpperCase(); + }); +} + +export const ColorSwatchGroup: React.FC = ({ swatchData, - borderColor, - textBackgroundColor, - textColor, + textBackgroundColor = 'transparent', + theme = '#ffffff', }) => { if (!swatchData) { return
No swatch data
; } - const swatchColorsArr = Object.keys(swatchData); + + // Function to extract numbers and sort them numerically + const sortShadesNumerically = (a: string, b: string) => { + const numberPattern = /\d+/; // Matches digits in the shade identifier + const numberA = parseInt(a.match(numberPattern)?.[0] || '0', 10); + const numberB = parseInt(b.match(numberPattern)?.[0] || '0', 10); + return numberA - numberB; + }; const renderSwatches = () => { - return swatchColorsArr.map((category) => { - const colorsObj = swatchData[category]; - let colorsArr: any = []; - const recursiveColors = (nextLevel, label) => { - for (const key in nextLevel) { - const level = nextLevel[key]; - if (level.value) { - colorsArr.push({ - label: `${label}${key}`, - value: level.value, - description: level.description, - }); - continue; - } - recursiveColors(level, `${label}${key}.`); - } - }; - recursiveColors(colorsObj, ''); - return ( -
-

{category}

+ return Object.entries(swatchData).map(([category, colors]) => { + if (colors.value) { + // For single color entries like white and black + const { value, description } = colors as any; // TypeScript workaround + return ( +
+

{category}

+
+
+ + {description && ( +

{description}

+ )} +
+
+
+ ); + } else { + // For grouped color entries with shades + const colorKeys = Object.keys(colors) + .filter((key) => !/\-\d+%$/.test(key)) + .map((key) => ({ + originalKey: key, + camelCaseKey: toCamelCase(key), + })); + + const sortedColorKeys = colorKeys.sort((a, b) => + sortShadesNumerically(a.camelCaseKey, b.camelCaseKey), + ); + + return (
- {colorsArr.map((color) => { - return ( -
- - {color?.description ? ( -

{color?.description}

- ) : null} -
- ); - })} +

{category}

+
+ {sortedColorKeys.map(({ originalKey, camelCaseKey }) => { + const colorDetails = colors[originalKey]; + const { value = '', description } = colorDetails || {}; + return ( +
+ + {description && ( +

{description}

+ )} +
+ ); + })} +
-
- ); + ); + } }); }; diff --git a/docs/utils/getContrastYIQ.ts b/docs/utils/getContrastYIQ.ts new file mode 100644 index 0000000..1ff6bc7 --- /dev/null +++ b/docs/utils/getContrastYIQ.ts @@ -0,0 +1,51 @@ +/** + * Determines the appropriate contrast text color (black or white) based on the given background color. + * The function takes into account the alpha transparency of the hex color, blending it with the background color if necessary. + * + * @param hexcolor - The hex color code which may include alpha transparency (e.g., '#RRGGBBAA'). + * @param backgroundColor - The hex color code of the default background color hexcolor will appear on (e.g., '#RRGGBB'). + * @returns Returns 'black' if the contrast is better with black text, otherwise returns 'white'. + */ +export const getContrastYIQ = ( + hexcolor: string, + backgroundColor: string, +): string => { + // Remove the '#' from the hex color if present + const modifiedHexcolor = hexcolor.replace('#', ''); + + // Variables to store the red, green, blue, and alpha values + let red: number; + let green: number; + let blue: number; + let a = 1; // Default alpha value is 1 (fully opaque) + + // Check if the hex color includes alpha transparency + if (modifiedHexcolor.length === 8) { + // If alpha is present (RRGGBBAA) + red = parseInt(modifiedHexcolor.slice(0, 2), 16); + green = parseInt(modifiedHexcolor.slice(2, 4), 16); + blue = parseInt(modifiedHexcolor.slice(4, 6), 16); + a = parseInt(modifiedHexcolor.slice(6, 8), 16) / 255; // Convert alpha to a range of 0 to 1 + } else { + // If no alpha is present (RRGGBB) + red = parseInt(modifiedHexcolor.slice(0, 2), 16); + green = parseInt(modifiedHexcolor.slice(2, 4), 16); + blue = parseInt(modifiedHexcolor.slice(4, 6), 16); + } + + // Extract the RGB values from the background color + const bgR = parseInt(backgroundColor.slice(1, 3), 16); + const bgG = parseInt(backgroundColor.slice(3, 5), 16); + const bgB = parseInt(backgroundColor.slice(5, 7), 16); + + // Blend the text color with the background color based on the alpha value + red = Math.round(red * a + (1 - a) * bgR); + green = Math.round(green * a + (1 - a) * bgG); + blue = Math.round(blue * a + (1 - a) * bgB); + + // Calculate the YIQ value to determine the brightness + const yiq = (red * 299 + green * 587 + blue * 114) / 1000; + + // Return 'black' if the YIQ value is 128 or greater, otherwise return 'white' + return yiq >= 128 ? 'black' : 'white'; +}; diff --git a/docs/utils/getJSColors.ts b/docs/utils/getJSColors.ts new file mode 100644 index 0000000..d72cff3 --- /dev/null +++ b/docs/utils/getJSColors.ts @@ -0,0 +1,19 @@ +/** + * Recursively collects color values and their names from a JavaScript color object using dot notation. + * + * @param obj - The color object to traverse. + * @param parentKey - The parent key to use for dot notation. + * @returns An array of objects containing color value and name. + */ +export const getJSColors = ( + obj: any, + parentKey = '', +): { name: string; color: string }[] => { + return Object.entries(obj).flatMap(([key, value]) => { + const newKey = parentKey ? `${parentKey}.${key}` : key; + if (typeof value === 'string') { + return [{ name: newKey, color: value }]; + } + return getJSColors(value, newKey); + }); +}; diff --git a/docs/utils/index.ts b/docs/utils/index.ts index 4350a45..ae23743 100644 --- a/docs/utils/index.ts +++ b/docs/utils/index.ts @@ -1,2 +1,4 @@ export { getCSSVariablesFromStylesheet } from './getCSSVariablesFromStylesheet'; +export { getContrastYIQ } from './getContrastYIQ'; +export { getJSColors } from './getJSColors'; export { useJsonColor } from './useJsonColor'; diff --git a/docs/utils/useJsonColor.ts b/docs/utils/useJsonColor.ts index d866753..981f718 100644 --- a/docs/utils/useJsonColor.ts +++ b/docs/utils/useJsonColor.ts @@ -6,9 +6,9 @@ import figmaLightTheme from '../../src/figma/lightTheme.json'; export type ColorDetails = { value: string; // Hex value or alias to another color - type: string; // Type usually color - parent: string; // Parent category or group of the color - description: string; // Description or notes about the color + type?: string; // Type usually color + parent?: string; // Parent category or group of the color + description?: string; // Description or notes about the color }; export type ColorPalette = { @@ -16,13 +16,16 @@ export type ColorPalette = { }; export type Theme = { - [colorName: string]: ColorPalette; + [colorName: string]: ColorPalette | ColorDetails; }; type CompiledColors = { [themeName: string]: Theme; }; +const isHexColor = (value: string) => + /^#[0-9A-F]{6}$/iu.test(value) || /^#[0-9A-F]{8}$/iu.test(value); + /** * Custom hook for compiling color themes from Figma JSON definitions. * Merges brand, light, and dark theme color settings into a single object. @@ -70,22 +73,48 @@ export const useJsonColor = (): CompiledColors => { Object.entries(themes).forEach(([themeName, theme]) => { const tempThemeColors: Theme = {}; Object.entries(theme).forEach(([colorName, colorValues]) => { - const tempThemeColorPalette: ColorPalette = {}; - Object.entries(colorValues).forEach(([shade, details]) => { - const { value, description } = details; - const resolvedValue = parseColorValue(value, figmaBrandColors); - const tempShadeColor = { - ...details, - value: resolvedValue, - description: - description + (value === resolvedValue ? '' : ` ${value}`), + if (typeof colorValues.value === 'string') { + tempThemeColors[colorName] = { + ...colorValues, + value: parseColorValue(colorValues.value, figmaBrandColors), }; - tempThemeColorPalette[shade] = tempShadeColor; - }); - tempThemeColors[colorName] = tempThemeColorPalette; + } else { + const tempThemeColorPalette: ColorPalette = {}; + Object.entries(colorValues).forEach(([shade, details]) => { + let resolvedValue = parseColorValue( + details.value, + figmaBrandColors, + ); + if (!isHexColor(resolvedValue)) { + const cleanResolvedValue = resolvedValue + .slice(1, -1) + .split('.'); // Split the reference into parts + const category = cleanResolvedValue[0]; // Get the category (e.g., 'text') + const key = cleanResolvedValue[1]; // Get the key (e.g., 'default') + if (theme[category]?.[key]) { + resolvedValue = parseColorValue( + theme[category][key].value, + figmaBrandColors, + ); + } else { + console.error('Invalid reference:', resolvedValue); + } + } + + tempThemeColorPalette[shade] = { + ...details, + value: resolvedValue, + description: + (details.description ?? '') + + (details.value === resolvedValue ? '' : ` ${details.value}`), + }; + }); + tempThemeColors[colorName] = tempThemeColorPalette; + } }); compiledColors[themeName] = tempThemeColors; }); + return compiledColors; }; diff --git a/src/figma/brandColors.json b/src/figma/brandColors.json index e13aa1b..3a66878 100644 --- a/src/figma/brandColors.json +++ b/src/figma/brandColors.json @@ -608,5 +608,17 @@ "parent": "Brand Colors/v1 - current", "description": "" } + }, + "white": { + "value": "#ffffff", + "type": "color", + "parent": "Brand Colors/v1 - current", + "description": "" + }, + "black": { + "value": "#000000", + "type": "color", + "parent": "Brand Colors/v1 - current", + "description": "" } }