diff --git a/packages/components/src/theme/ThemeUtils.test.ts b/packages/components/src/theme/ThemeUtils.test.ts index 7ad550d9bb..8ff4a64e5b 100644 --- a/packages/components/src/theme/ThemeUtils.test.ts +++ b/packages/components/src/theme/ThemeUtils.test.ts @@ -1,102 +1,26 @@ -import { TestUtils } from '@deephaven/utils'; +import { ColorUtils, TestUtils } from '@deephaven/utils'; +import shortid from 'shortid'; import { + DEFAULT_DARK_THEME_KEY, DEFAULT_PRELOAD_DATA_VARIABLES, ThemeData, ThemeRegistrationData, THEME_CACHE_LOCAL_STORAGE_KEY, } from './ThemeModel'; import { - asRgbOrRgbaString, calculatePreloadStyleContent, getActiveThemes, getDefaultBaseThemes, getThemeKey, getThemePreloadData, - normalizeCssColor, - parseRgba, preloadTheme, - rgbaToHex8, + replaceCssVariablesWithResolvedValues, setThemePreloadData, } from './ThemeUtils'; -const { createMockProxy } = TestUtils; +jest.mock('shortid'); -const getBackgroundColor = jest.fn(); -const setBackgroundColor = jest.fn(); - -const mockDivEl = createMockProxy({ - style: { - get backgroundColor(): string { - return getBackgroundColor(); - }, - set backgroundColor(value: string) { - setBackgroundColor(value); - }, - } as HTMLDivElement['style'], -}); - -const colorMap = [ - { - hsl: { h: 0, s: 100, l: 50 }, - rgb: { r: 255, g: 0, b: 0 }, - hex: '#ff0000ff', - }, - { - hsl: { h: 30, s: 100, l: 50 }, - rgb: { r: 255, g: 128, b: 0 }, - hex: '#ff8000ff', - }, - { - hsl: { h: 60, s: 100, l: 50 }, - rgb: { r: 255, g: 255, b: 0 }, - hex: '#ffff00ff', - }, - { - hsl: { h: 90, s: 100, l: 50 }, - rgb: { r: 128, g: 255, b: 0 }, - hex: '#80ff00ff', - }, - { - hsl: { h: 120, s: 100, l: 50 }, - rgb: { r: 0, g: 255, b: 0 }, - hex: '#00ff00ff', - }, - { - hsl: { h: 150, s: 100, l: 50 }, - rgb: { r: 0, g: 255, b: 128 }, - hex: '#00ff80ff', - }, - { - hsl: { h: 180, s: 100, l: 50 }, - rgb: { r: 0, g: 255, b: 255 }, - hex: '#00ffffff', - }, - { - hsl: { h: 210, s: 100, l: 50 }, - rgb: { r: 0, g: 128, b: 255 }, - hex: '#0080ffff', - }, - { - hsl: { h: 240, s: 100, l: 50 }, - rgb: { r: 0, g: 0, b: 255 }, - hex: '#0000ffff', - }, - { - hsl: { h: 270, s: 100, l: 50 }, - rgb: { r: 128, g: 0, b: 255 }, - hex: '#8000ffff', - }, - { - hsl: { h: 300, s: 100, l: 50 }, - rgb: { r: 255, g: 0, b: 255 }, - hex: '#ff00ffff', - }, - { - hsl: { h: 330, s: 100, l: 50 }, - rgb: { r: 255, g: 0, b: 128 }, - hex: '#ff0080ff', - }, -]; +const { asMock, createMockProxy } = TestUtils; beforeEach(() => { document.body.removeAttribute('style'); @@ -105,32 +29,6 @@ beforeEach(() => { jest.clearAllMocks(); jest.restoreAllMocks(); expect.hasAssertions(); - - getBackgroundColor.mockName('getBackgroundColor'); - setBackgroundColor.mockName('setBackgroundColor'); -}); - -describe('asRgbOrRgbaString', () => { - beforeEach(() => { - jest - .spyOn(document, 'createElement') - .mockName('createElement') - .mockReturnValue(mockDivEl); - }); - - it('should return resolved backgroundColor value', () => { - getBackgroundColor.mockReturnValue('get backgroundColor'); - - const actual = asRgbOrRgbaString('red'); - expect(actual).toEqual('get backgroundColor'); - }); - - it('should return null if backgroundColor resolves to empty string', () => { - getBackgroundColor.mockReturnValue(''); - - const actual = asRgbOrRgbaString('red'); - expect(actual).toBeNull(); - }); }); describe('calculatePreloadStyleContent', () => { @@ -152,15 +50,21 @@ describe('calculatePreloadStyleContent', () => { describe('getActiveThemes', () => { const mockTheme = { + default: { + name: 'Default Theme', + baseThemeKey: undefined, + themeKey: DEFAULT_DARK_THEME_KEY, + styleContent: '', + }, base: { name: 'Base Theme', baseThemeKey: undefined, - themeKey: 'default-dark', + themeKey: 'default-light', styleContent: '', }, custom: { name: 'Custom Theme', - baseThemeKey: 'default-dark', + baseThemeKey: 'default-light', themeKey: 'customTheme', styleContent: '', }, @@ -172,10 +76,15 @@ describe('getActiveThemes', () => { } satisfies Record; const themeRegistration: ThemeRegistrationData = { - base: [mockTheme.base], + base: [mockTheme.default, mockTheme.base], custom: [mockTheme.custom], }; + it('should use default dark theme if no base theme is matched', () => { + const actual = getActiveThemes('somekey', themeRegistration); + expect(actual).toEqual([mockTheme.default]); + }); + it.each([null, mockTheme.customInvalid])( 'should throw if base theme not found', customTheme => { @@ -259,67 +168,6 @@ describe('getThemePreloadData', () => { ); }); -describe('normalizeCssColor', () => { - beforeEach(() => { - jest - .spyOn(document, 'createElement') - .mockName('createElement') - .mockReturnValue(mockDivEl); - }); - - it.each([ - 'rgb(0, 128, 255)', - 'rgba(0, 128, 255, 64)', - 'rgb(0 128 255)', - 'rgba(0 128 255 64)', - ])( - 'should normalize a resolved rgb/a color to 8 character hex value', - rgbOrRgbaColor => { - getBackgroundColor.mockReturnValue(rgbOrRgbaColor); - - const actual = normalizeCssColor('some.color'); - expect(actual).toEqual(rgbaToHex8(parseRgba(rgbOrRgbaColor)!)); - } - ); - - it('should return original color if backgroundColor resolves to empty string', () => { - getBackgroundColor.mockReturnValue(''); - - const actual = normalizeCssColor('red'); - expect(actual).toEqual('red'); - }); - - it('should return original color if backgroundColor resolves to non rgb/a', () => { - getBackgroundColor.mockReturnValue('xxx'); - - const actual = normalizeCssColor('red'); - expect(actual).toEqual('red'); - }); -}); - -describe('parseRgba', () => { - it.only.each([ - ['rgb(255, 255, 255)', { r: 255, g: 255, b: 255, a: 1 }], - ['rgb(0,0,0)', { r: 0, g: 0, b: 0, a: 1 }], - ['rgb(255 255 255)', { r: 255, g: 255, b: 255, a: 1 }], - ['rgb(0 0 0)', { r: 0, g: 0, b: 0, a: 1 }], - ['rgb(0 128 255)', { r: 0, g: 128, b: 255, a: 1 }], - ['rgb(0 128 255 / .5)', { r: 0, g: 128, b: 255, a: 0.5 }], - ])('should parse rgb: %s, %s', (rgb, hex) => { - expect(parseRgba(rgb)).toEqual(hex); - }); - - it.each([ - ['rgba(255, 255, 255, 1)', { r: 255, g: 255, b: 255, a: 1 }], - ['rgba(0,0,0,0)', { r: 0, g: 0, b: 0, a: 0 }], - ['rgba(255 255 255 1)', { r: 255, g: 255, b: 255, a: 1 }], - ['rgba(0 0 0 0)', { r: 0, g: 0, b: 0, a: 0 }], - ['rgba(0 128 255 .5)', { r: 0, g: 128, b: 255, a: 0.5 }], - ])('should parse rgba: %s, %s', (rgba, hex) => { - expect(parseRgba(rgba)).toEqual(hex); - }); -}); - describe('preloadTheme', () => { it.each([ null, @@ -346,11 +194,83 @@ describe('preloadTheme', () => { }); }); -describe('rgbaToHex8', () => { - it.each(colorMap)('should convert rgb to hex: %s, %s', ({ rgb, hex }) => { - expect(rgbaToHex8(rgb)).toEqual(hex); - }); -}); +describe.each([undefined, document.createElement('div')])( + 'replaceCssVariablesWithResolvedValues', + targetElement => { + const mockShortId = 'mockShortId'; + const computedStyle = createMockProxy(); + const expectedEl = targetElement ?? document.body; + + beforeEach(() => { + asMock(shortid).mockName('shortid').mockReturnValue(mockShortId); + asMock(computedStyle.getPropertyValue) + .mockName('getPropertyValue') + .mockImplementation(key => `resolved-${key}`); + + jest.spyOn(expectedEl.style, 'setProperty').mockName('setProperty'); + jest.spyOn(expectedEl.style, 'removeProperty').mockName('removeProperty'); + + jest + .spyOn(ColorUtils, 'normalizeCssColor') + .mockName('normalizeCssColor') + .mockImplementation(key => `normalized-${key}`); + jest + .spyOn(window, 'getComputedStyle') + .mockName('getComputedStyle') + .mockReturnValue(computedStyle); + }); + + it('should map non-css variable values verbatim', () => { + const given = { + aaa: 'aaa', + bbb: 'bbb', + }; + + const actual = replaceCssVariablesWithResolvedValues( + given, + targetElement + ); + + expect(computedStyle.getPropertyValue).not.toHaveBeenCalled(); + expect(ColorUtils.normalizeCssColor).not.toHaveBeenCalled(); + expect(actual).toEqual(given); + }); + + it('should replace css variables with resolved values', () => { + const given = { + aaa: 'var(--aaa)', + bbb: 'var(--bbb1) var(--bbb2)', + }; + + const expected = { + aaa: 'normalized-resolved---mockShortId-aaa', + bbb: 'normalized-resolved---mockShortId-bbb', + }; + + const actual = replaceCssVariablesWithResolvedValues( + given, + targetElement + ); + + Object.keys(given).forEach(key => { + const tmpKey = `--${mockShortId}-${key}`; + + expect(expectedEl.style.setProperty).toHaveBeenCalledWith( + tmpKey, + given[key] + ); + + expect(computedStyle.getPropertyValue).toHaveBeenCalledWith(tmpKey); + expect(expectedEl.style.removeProperty).toHaveBeenCalledWith(tmpKey); + expect(ColorUtils.normalizeCssColor).toHaveBeenCalledWith( + `resolved-${tmpKey}` + ); + }); + + expect(actual).toEqual(expected); + }); + } +); describe('setThemePreloadData', () => { it('should set the theme preload data', () => { diff --git a/packages/components/src/theme/ThemeUtils.ts b/packages/components/src/theme/ThemeUtils.ts index 2f99bdf6ed..e624666b65 100644 --- a/packages/components/src/theme/ThemeUtils.ts +++ b/packages/components/src/theme/ThemeUtils.ts @@ -1,6 +1,6 @@ import Log from '@deephaven/log'; -import { assertNotNull } from '@deephaven/utils'; import shortid from 'shortid'; +import { assertNotNull, ColorUtils } from '@deephaven/utils'; // Note that ?inline imports are natively supported by Vite, but consumers of // @deephaven/components using Webpack will need to add a rule to their module // config. @@ -29,22 +29,6 @@ import { const log = Log.module('ThemeUtils'); -/** - * Attempt to get the rgb or rgba string for a color string. If the color string - * can't be resolved to a valid color, null is returned. - * @param colorString The color string to resolve - */ -export function asRgbOrRgbaString(colorString: string): string | null { - const divEl = document.createElement('div'); - divEl.style.backgroundColor = colorString; - - if (divEl.style.backgroundColor === '') { - return null; - } - - return divEl.style.backgroundColor; -} - /** * Creates a string containing preload style content for the current theme. * This resolves the current values of a few CSS variables that can be used @@ -137,80 +121,22 @@ export function getThemePreloadData(): ThemePreloadData | null { return null; } -/** - * Normalize a css color to 8 character hex value. If the color can't be resolved, - * return the original color string. - * @param colorString The color string to normalize - */ -export function normalizeCssColor(colorString: string): string { - const maybeRgbOrRgba = asRgbOrRgbaString(colorString); - if (maybeRgbOrRgba == null) { - return colorString; - } - - const rgba = parseRgba(maybeRgbOrRgba); - if (rgba === null) { - return colorString; - } - - return rgbaToHex8(rgba); -} - -/** - * Parse a given `rgb` or `rgba` css expression into its constituent r, g, b, a - * values. If the expression cannot be parsed, it will return null. - * Note that this parser is more permissive than the CSS spec and shouldn't be - * relied on as a full validation mechanism. For the most part, it assumes that - * the input is already a valid rgb or rgba expression. - * - * e.g. `rgb(255, 255, 255)` -> `{ r: 255, g: 255, b: 255, a: 1 }` - * e.g. `rgba(255, 255, 255, 0.5)` -> `{ r: 255, g: 255, b: 255, a: 0.5 }` - * @param rgbOrRgbaString The rgb or rgba string to parse - */ -export function parseRgba( - rgbOrRgbaString: string -): { r: number; g: number; b: number; a: number } | null { - const [, name, args] = /^(rgba?)\((.*?)\)$/.exec(rgbOrRgbaString) ?? []; - if (name == null) { - return null; - } - - // Split on spaces, commas, and slashes. Note that this more permissive than - // the CSS spec in that slashes should only be used to delimit the alpha value - // (e.g. r g b / a), but this would match r/g/b/a. It also would match a mixed - // delimiter case (e.g. r,g b,a). This seems like a reasonable tradeoff for the - // complexity that would be added to enforce the full spec. - const tokens = args.split(/[ ,/]/).filter(Boolean); - - if (tokens.length < 3) { - return null; - } - - const [r, g, b, a = 1] = tokens.map(Number); - - return { - r, - g, - b, - a, - }; -} - /** * Make a copy of the given object replacing any css variables contained in its * prop values with values resolved from the given HTML element. - * @param obj An object whose values may contain css var expressions - * @param el The element to resolve css variables against. Defaults to document.body + * @param record An object whose values may contain css var expressions + * @param targetElement The element to resolve css variables against. Defaults + * to document.body */ export function replaceCssVariablesWithResolvedValues< T extends Record, ->(obj: T, el: HTMLElement = document.body): T { +>(record: T, targetElement: HTMLElement = document.body): T { const prefix = shortid(); - const computedStyle = getComputedStyle(el); + const computedStyle = window.getComputedStyle(targetElement); const result = {} as T; - Object.entries(obj).forEach(([key, value]) => { + Object.entries(record).forEach(([key, value]) => { if (!value.includes('var(--')) { result[key as keyof T] = value as T[keyof T]; return; @@ -218,11 +144,11 @@ export function replaceCssVariablesWithResolvedValues< // Create a temporary css variable to resolve the value const tmpPropKey = `--${prefix}-${key}`; - el.style.setProperty(tmpPropKey, value); + targetElement.style.setProperty(tmpPropKey, value); const resolved = computedStyle.getPropertyValue(tmpPropKey); - el.style.removeProperty(tmpPropKey); + targetElement.style.removeProperty(tmpPropKey); - const normalized = normalizeCssColor(resolved); + const normalized = ColorUtils.normalizeCssColor(resolved); log.debug('Replaced css variables:', value, normalized); result[key as keyof T] = normalized as T[keyof T]; @@ -231,35 +157,6 @@ export function replaceCssVariablesWithResolvedValues< return result; } -/** - * Convert an rgba object to an 8 character hex color string. - * @param r The red value - * @param g The green value - * @param b The blue value - * @param a The alpha value (defaults to 1) - * @returns The a character hex string with # prefix - */ -export function rgbaToHex8({ - r, - g, - b, - a = 1, -}: { - r: number; - g: number; - b: number; - a?: number; -}): string { - // eslint-disable-next-line no-param-reassign - a = Math.round(a * 255); - - const [rh, gh, bh, ah] = [r, g, b, a].map(v => - v.toString(16).padStart(2, '0') - ); - - return `#${rh}${gh}${bh}${ah}`; -} - /** * Store theme preload data in local storage. * @param preloadData The preload data to set diff --git a/packages/utils/src/ColorUtils.test.ts b/packages/utils/src/ColorUtils.test.ts index 3616722903..5cc3166e82 100644 --- a/packages/utils/src/ColorUtils.test.ts +++ b/packages/utils/src/ColorUtils.test.ts @@ -1,4 +1,104 @@ import ColorUtils from './ColorUtils'; +import TestUtils from './TestUtils'; + +const { createMockProxy } = TestUtils; + +const getBackgroundColor = jest.fn(); +const setBackgroundColor = jest.fn(); + +const mockDivEl = createMockProxy({ + style: { + get backgroundColor(): string { + return getBackgroundColor(); + }, + set backgroundColor(value: string) { + setBackgroundColor(value); + }, + } as HTMLDivElement['style'], +}); + +const colorMap = [ + { + rgb: { r: 255, g: 0, b: 0 }, + hex: '#ff0000ff', + }, + { + rgb: { r: 255, g: 128, b: 0 }, + hex: '#ff8000ff', + }, + { + rgb: { r: 255, g: 255, b: 0 }, + hex: '#ffff00ff', + }, + { + rgb: { r: 128, g: 255, b: 0 }, + hex: '#80ff00ff', + }, + { + rgb: { r: 0, g: 255, b: 0 }, + hex: '#00ff00ff', + }, + { + rgb: { r: 0, g: 255, b: 128 }, + hex: '#00ff80ff', + }, + { + rgb: { r: 0, g: 255, b: 255 }, + hex: '#00ffffff', + }, + { + rgb: { r: 0, g: 128, b: 255 }, + hex: '#0080ffff', + }, + { + rgb: { r: 0, g: 0, b: 255 }, + hex: '#0000ffff', + }, + { + rgb: { r: 128, g: 0, b: 255 }, + hex: '#8000ffff', + }, + { + rgb: { r: 255, g: 0, b: 255 }, + hex: '#ff00ffff', + }, + { + rgb: { r: 255, g: 0, b: 128 }, + hex: '#ff0080ff', + }, +]; + +beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + expect.hasAssertions(); + + getBackgroundColor.mockName('getBackgroundColor'); + setBackgroundColor.mockName('setBackgroundColor'); +}); + +describe('asRgbOrRgbaString', () => { + beforeEach(() => { + jest + .spyOn(document, 'createElement') + .mockName('createElement') + .mockReturnValue(mockDivEl); + }); + + it('should return resolved backgroundColor value', () => { + getBackgroundColor.mockReturnValue('get backgroundColor'); + + const actual = ColorUtils.asRgbOrRgbaString('red'); + expect(actual).toEqual('get backgroundColor'); + }); + + it('should return null if backgroundColor resolves to empty string', () => { + getBackgroundColor.mockReturnValue(''); + + const actual = ColorUtils.asRgbOrRgbaString('red'); + expect(actual).toBeNull(); + }); +}); describe('isDark', () => { it('returns true if the background is dark', () => { @@ -21,3 +121,83 @@ describe('isDark', () => { expect(() => ColorUtils.isDark('')).toThrowError(/Invalid color received/); }); }); + +describe('normalizeCssColor', () => { + beforeEach(() => { + jest + .spyOn(document, 'createElement') + .mockName('createElement') + .mockReturnValue(mockDivEl); + }); + + it.each([ + 'rgb(0, 128, 255)', + 'rgba(0, 128, 255, 64)', + 'rgb(0 128 255)', + 'rgba(0 128 255 64)', + ])( + 'should normalize a resolved rgb/a color to 8 character hex value', + rgbOrRgbaColor => { + getBackgroundColor.mockReturnValue(rgbOrRgbaColor); + + const actual = ColorUtils.normalizeCssColor('some.color'); + expect(actual).toEqual( + ColorUtils.rgbaToHex8(ColorUtils.parseRgba(rgbOrRgbaColor)!) + ); + } + ); + + it('should return original color if backgroundColor resolves to empty string', () => { + getBackgroundColor.mockReturnValue(''); + + const actual = ColorUtils.normalizeCssColor('red'); + expect(actual).toEqual('red'); + }); + + it('should return original color if backgroundColor resolves to non rgb/a', () => { + getBackgroundColor.mockReturnValue('xxx'); + + const actual = ColorUtils.normalizeCssColor('red'); + expect(actual).toEqual('red'); + }); +}); + +describe('parseRgba', () => { + it.each([ + ['rgb(255, 255, 255)', { r: 255, g: 255, b: 255, a: 1 }], + ['rgb(0,0,0)', { r: 0, g: 0, b: 0, a: 1 }], + ['rgb(255 255 255)', { r: 255, g: 255, b: 255, a: 1 }], + ['rgb(0 0 0)', { r: 0, g: 0, b: 0, a: 1 }], + ['rgb(0 128 255)', { r: 0, g: 128, b: 255, a: 1 }], + ['rgb(0 128 255 / .5)', { r: 0, g: 128, b: 255, a: 0.5 }], + ])('should parse rgb: %s, %s', (rgb, hex) => { + expect(ColorUtils.parseRgba(rgb)).toEqual(hex); + }); + + it.each([ + ['rgba(255, 255, 255, 1)', { r: 255, g: 255, b: 255, a: 1 }], + ['rgba(0,0,0,0)', { r: 0, g: 0, b: 0, a: 0 }], + ['rgba(255 255 255 1)', { r: 255, g: 255, b: 255, a: 1 }], + ['rgba(0 0 0 0)', { r: 0, g: 0, b: 0, a: 0 }], + ['rgba(0 128 255 .5)', { r: 0, g: 128, b: 255, a: 0.5 }], + ])('should parse rgba: %s, %s', (rgba, hex) => { + expect(ColorUtils.parseRgba(rgba)).toEqual(hex); + }); + + it('should return null if not rgb or rgba', () => { + expect(ColorUtils.parseRgba('xxx')).toBeNull(); + }); + + it.each(['rgb(0 128)', 'rgba(0 128)', 'rgb(0, 128)', 'rgba(0, 128)'])( + 'should return null if given < 3 args', + value => { + expect(ColorUtils.parseRgba(value)).toBeNull(); + } + ); +}); + +describe('rgbaToHex8', () => { + it.each(colorMap)('should convert rgb to hex: %s, %s', ({ rgb, hex }) => { + expect(ColorUtils.rgbaToHex8(rgb)).toEqual(hex); + }); +}); diff --git a/packages/utils/src/ColorUtils.ts b/packages/utils/src/ColorUtils.ts index 6531fedec0..560b9fd6aa 100644 --- a/packages/utils/src/ColorUtils.ts +++ b/packages/utils/src/ColorUtils.ts @@ -1,4 +1,20 @@ class ColorUtils { + /** + * Attempt to get the rgb or rgba string for a color string. If the color string + * can't be resolved to a valid color, null is returned. + * @param colorString The color string to resolve + */ + static asRgbOrRgbaString(colorString: string): string | null { + const divEl = document.createElement('div'); + divEl.style.backgroundColor = colorString; + + if (divEl.style.backgroundColor === '') { + return null; + } + + return divEl.style.backgroundColor; + } + /** * THIS HAS POOR PERFORMANCE DUE TO DOM MANIPULATION * DO NOT USE HEAVILY @@ -32,5 +48,93 @@ class ColorUtils { (color[0] * 299 + color[1] * 587 + color[2] * 114) / 1000 ); } + + /** + * Normalize a css color to 8 character hex value. If the color can't be resolved, + * return the original color string. + * @param colorString The color string to normalize + */ + static normalizeCssColor(colorString: string): string { + const maybeRgbOrRgba = ColorUtils.asRgbOrRgbaString(colorString); + if (maybeRgbOrRgba == null) { + return colorString; + } + + const rgba = ColorUtils.parseRgba(maybeRgbOrRgba); + if (rgba === null) { + return colorString; + } + + return ColorUtils.rgbaToHex8(rgba); + } + + /** + * Parse a given `rgb` or `rgba` css expression into its constituent r, g, b, a + * values. If the expression cannot be parsed, it will return null. + * Note that this parser is more permissive than the CSS spec and shouldn't be + * relied on as a full validation mechanism. For the most part, it assumes that + * the input is already a valid rgb or rgba expression. + * + * e.g. `rgb(255, 255, 255)` -> `{ r: 255, g: 255, b: 255, a: 1 }` + * e.g. `rgba(255, 255, 255, 0.5)` -> `{ r: 255, g: 255, b: 255, a: 0.5 }` + * @param rgbOrRgbaString The rgb or rgba string to parse + */ + static parseRgba( + rgbOrRgbaString: string + ): { r: number; g: number; b: number; a: number } | null { + const [, name, args] = /^(rgba?)\((.*?)\)$/.exec(rgbOrRgbaString) ?? []; + if (name == null) { + return null; + } + + // Split on spaces, commas, and slashes. Note that this more permissive than + // the CSS spec in that slashes should only be used to delimit the alpha value + // (e.g. r g b / a), but this would match r/g/b/a. It also would match a mixed + // delimiter case (e.g. r,g b,a). This seems like a reasonable tradeoff for the + // complexity that would be added to enforce the full spec. + const tokens = args.split(/[ ,/]/).filter(Boolean); + + if (tokens.length < 3) { + return null; + } + + const [r, g, b, a = 1] = tokens.map(Number); + + return { + r, + g, + b, + a, + }; + } + + /** + * Convert an rgba object to an 8 character hex color string. + * @param r The red value + * @param g The green value + * @param b The blue value + * @param a The alpha value (defaults to 1) + * @returns The a character hex string with # prefix + */ + static rgbaToHex8({ + r, + g, + b, + a = 1, + }: { + r: number; + g: number; + b: number; + a?: number; + }): string { + // eslint-disable-next-line no-param-reassign + a = Math.round(a * 255); + + const [rh, gh, bh, ah] = [r, g, b, a].map(v => + v.toString(16).padStart(2, '0') + ); + + return `#${rh}${gh}${bh}${ah}`; + } } export default ColorUtils;