Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions modules/labs-react/common/lib/theming/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './useThemeRTL';
export * from './palette';
215 changes: 215 additions & 0 deletions modules/labs-react/common/lib/theming/palette/alpha.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
/**
* Alpha compositing utilities for transparent colors over a background.
* Porter-Duff blending. @see https://www.w3.org/TR/compositing-1/
*/
import {clamp, hexToRgb} from './conversion';
import type {RGB} from './types';

/**
* Result of alpha color calculation
*/
export interface AlphaColorResult {
/** RGB channels (values 0-1) */
color: RGB;
/** Alpha channel (0-1) */
alpha: number;
/** CSS rgba(r, g, b, a) string */
rgba: string;
/** Hex with alpha (#RRGGBBAA) */
hex: string;
}

/**
* Checks if color is valid RGB
*/
const isRGB = (color: RGB, param: string): void => {
if (!Array.isArray(color) || color.length !== 3) {
throw new TypeError(`${param} must be an RGB tuple [r, g, b]`);
}
if (
typeof color[0] !== 'number' ||
typeof color[1] !== 'number' ||
typeof color[2] !== 'number'
) {
throw new TypeError(`${param} must contain numeric values`);
}
if (!isFinite(color[0]) || !isFinite(color[1]) || !isFinite(color[2])) {
throw new RangeError(`${param} values must be finite numbers`);
}
};

const parseColor = (input: RGB | string): RGB =>
typeof input === 'string' ? hexToRgb(input) : input;

/**
* Min alpha needed to represent the solid color using the background.
* @param color - Target RGB (0-1)
* @param bgColor - Background RGB (0-1)
* @returns Alpha 0.01-0.99
*/
export const minAlpha = (color: RGB, bgColor: RGB): number => {
isRGB(color, 'color');
isRGB(bgColor, 'bgColor');

let result = 0.01;

for (let i = 0; i < 3; i++) {
const out = color[i];
const bg = bgColor[i];
// < 0.0001 is the same
if (Math.abs(out - bg) < 0.0001) {
continue;
}

const required = out > bg ? (out - bg) / (1 - bg) : (bg - out) / bg;
if (required > result && required <= 1) {
result = required;
}
}

return clamp(result, 0, 1) * 0.99 + 0.01;
};

const alphaChannel = (target: number, bg: number, a: number): number => {
if (a <= 0.001) {
return target;
}
return clamp((target - bg * (1 - a)) / a, 0, 1);
};

/**
* Calculates an alpha color that composites to the target over the background.
* Inverse of composite: composite(alpha(target, bg, a), bg, a) equals target.
*
* @param color - Target color as RGB tuple (values 0-1)
* @param bgColor - Background color as RGB tuple (values 0-1)
* @param a - Alpha (0-1)
* @returns Transparent RGB tuple that composites to color over bgColor
*/
export const alpha = (color: RGB, bgColor: RGB, a: number): RGB => [
alphaChannel(color[0], bgColor[0], a),
alphaChannel(color[1], bgColor[1], a),
alphaChannel(color[2], bgColor[2], a),
];

/**
* Composites a color with alpha over a background.
* Output = alpha × Foreground + (1 - alpha) × Background
*
* @param color - Foreground color as RGB tuple (values 0-1)
* @param bgColor - Background color as RGB tuple (values 0-1)
* @param a - Alpha (0-1), clamped
* @returns Composited RGB tuple
*
* @example
* composite([0,0,0], [1,1,1], 0.5) // [0.5, 0.5, 0.5]
*/
export const composite = (color: RGB, bgColor: RGB, a: number): RGB => {
const clamped = clamp(a, 0, 1);
return [
clamped * color[0] + (1 - clamped) * bgColor[0],
clamped * color[1] + (1 - clamped) * bgColor[1],
clamped * color[2] + (1 - clamped) * bgColor[2],
];
};

/**
* Formats RGB to CSS rgb() or rgba() string
*
* @param color - RGB (0-1)
* @param alpha - If omitted, rgb(); if provided, rgba()
* @returns CSS color string
*/
export const formatRGBA = (color: RGB, alpha?: number): string => {
const r = Math.round(clamp(color[0], 0, 1) * 255);
const g = Math.round(clamp(color[1], 0, 1) * 255);
const b = Math.round(clamp(color[2], 0, 1) * 255);
if (alpha === undefined) {
return `rgb(${r}, ${g}, ${b})`;
}
return `rgba(${r}, ${g}, ${b}, ${clamp(alpha, 0, 1).toFixed(2)})`;
};

/**
* Converts RGB to hex string
*
* @param color - RGB tuple (values 0-1)
* @param alpha - If omitted, #RRGGBB; if provided, #RRGGBBAA
* @returns Hex string (#RRGGBB or #RRGGBBAA)
*/
export const rgbToHex = (color: RGB, alpha?: number): string => {
const toHex = (v: number) =>
Math.round(clamp(v, 0, 1) * 255)
.toString(16)
.padStart(2, '0');
const hex = `#${toHex(color[0])}${toHex(color[1])}${toHex(color[2])}`;
if (alpha === undefined) {
return hex;
}
return `${hex}${toHex(alpha)}`;
};

/**
* Calculates an alpha color that appears identical when composited over the background.
* Accepts either RGB tuples or hex strings (#RGB, #RRGGBB).
*
* @param color - Target color as RGB tuple (values 0-1)
* @param bg - Background as RGB tuple (default: white [1,1,1])
* @param targetAlpha - Alpha (0-1). Omit to use minimum required. If too low to achieve target, returns original at 100%.
* @returns {AlphaColorResult}
* @throws {TypeError} Invalid color/bg or targetAlpha
* @throws {RangeError} NaN or Infinity in color values
* @throws {Error} Invalid hex format
*
* @example
* alphaColor([0.5,0.5,0.5], [1,1,1])
* alphaColor('#0875E1', '#FFFFFF', 0.85)
*/
export function alphaColor(color: RGB, bg?: RGB, targetAlpha?: number): AlphaColorResult;
export function alphaColor(
hexColor: string,
bgHex?: string,
targetAlpha?: number
): AlphaColorResult;
export function alphaColor(
colorOrHex: RGB | string,
bg?: RGB | string,
targetAlpha?: number
): AlphaColorResult {
if (targetAlpha !== undefined && (typeof targetAlpha !== 'number' || !isFinite(targetAlpha))) {
throw new TypeError('targetAlpha must be a finite number between 0 and 1');
}

const color = parseColor(colorOrHex);
const bgColor = bg !== undefined ? parseColor(bg) : ([1, 1, 1] as RGB);

isRGB(color, 'color');
isRGB(bgColor, 'bgColor');

const a = targetAlpha !== undefined ? clamp(targetAlpha, 0, 1) : minAlpha(color, bgColor);
const transparent = alpha(color, bgColor, a);
const composited = composite(transparent, bgColor, a);

// Target not achievable at this alpha (values were clamped) — use solid color
const eps = 1 / 255;
const matches =
Math.abs(composited[0] - color[0]) < eps &&
Math.abs(composited[1] - color[1]) < eps &&
Math.abs(composited[2] - color[2]) < eps;

if (!matches) {
return {
color,
alpha: 1,
rgba: formatRGBA(color, 1),
hex: rgbToHex(color, 1),
};
}

return {
color: transparent,
alpha: a,
rgba: formatRGBA(transparent, a),
hex: rgbToHex(transparent, a),
};
}
76 changes: 76 additions & 0 deletions modules/labs-react/common/lib/theming/palette/colorjs.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* Type declarations for colorjs.io
*
* colorjs.io ships with TypeScript types but uses the `exports` field
* which requires `moduleResolution: "node16"` or higher.
* This declaration file provides a workaround for older module resolution.
*/

declare module 'colorjs.io' {
export interface Coords extends Array<number | null> {
0: number | null;
1: number | null;
2: number | null;
}

export interface ColorObject {
spaceId: string;
coords: Coords;
alpha: number;
}

export interface SerializeOptions {
format?: string | {name: string};
precision?: number;
inGamut?: boolean;
}

export default class Color {
constructor(color: string | Color | ColorObject);
constructor(space: string, coords: [number, number, number], alpha?: number);

spaceId: string;
coords: Coords;
alpha: number;

/**
* Convert to another color space
*/
to(space: string): Color;

/**
* Convert to string representation
*/
toString(options?: SerializeOptions): string;

/**
* Check if color is within gamut
*/
inGamut(space?: string): boolean;

/**
* Map color to gamut
*/
toGamut(space?: string): Color;

/**
* Calculate contrast ratio against another color
*/
contrast(color: Color, algorithm?: 'WCAG21' | 'APCA' | 'Lstar' | 'DeltaPhi'): number;

/**
* Clone the color
*/
clone(): Color;

/**
* Get color in a specific format
*/
get(prop: string): number;

/**
* Set color property
*/
set(prop: string, value: number): Color;
}
}
Loading
Loading