Skip to content
Open
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
73 changes: 32 additions & 41 deletions packages/react-native-ui-lib/src/style/__tests__/colors.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,46 +92,33 @@ describe('style/Colors', () => {
});

it('should handle color that does not exist in `uilib`', () => {
expect(uut.getColorTint('#F1BE0B', 10)).toEqual('#8D7006'); //
expect(uut.getColorTint('#F1BE0B', 20)).toEqual('#BE9609'); //
expect(uut.getColorTint('#F1BE0B', 30)).toEqual('#F1BE0B'); //
expect(uut.getColorTint('#F1BE0B', 40)).toEqual('#F6CC37'); //
expect(uut.getColorTint('#F1BE0B', 50)).toEqual('#F8D868'); //
expect(uut.getColorTint('#F1BE0B', 60)).toEqual('#FAE599'); //
expect(uut.getColorTint('#F1BE0B', 70)).toEqual('#FDF1C9'); //
expect(uut.getColorTint('#F1BE0B', 80)).toEqual('#FFFEFA'); //
expect(uut.getColorTint('#F1BE0B', 10)).toEqual('#7E6715');
expect(uut.getColorTint('#F1BE0B', 20)).toEqual('#B59112');
expect(uut.getColorTint('#F1BE0B', 30)).toEqual('#F1BE0B');
expect(uut.getColorTint('#F1BE0B', 40)).toEqual('#ECC741');
expect(uut.getColorTint('#F1BE0B', 50)).toEqual('#E8CF78');
expect(uut.getColorTint('#F1BE0B', 60)).toEqual('#EADCA9');
expect(uut.getColorTint('#F1BE0B', 70)).toEqual('#F1EBD5');
expect(uut.getColorTint('#F1BE0B', 80)).toEqual('#FEFDFB');
});

it('should round down tint level to the nearest one', () => {
expect(uut.getColorTint('#F1BE0B', 75)).toEqual('#FDF1C9');
expect(uut.getColorTint('#F1BE0B', 25)).toEqual('#BE9609');
expect(uut.getColorTint('#F1BE0B', 75)).toEqual('#F1EBD5');
expect(uut.getColorTint('#F1BE0B', 25)).toEqual('#B59112');
expect(uut.getColorTint('#F1BE0B', 35)).toEqual('#F1BE0B');
});

it('should handle out of range tint levels and round them to the nearest one in range', () => {
expect(uut.getColorTint('#F1BE0B', 3)).toEqual('#8D7006');
expect(uut.getColorTint('#F1BE0B', 95)).toEqual('#FFFEFA');
expect(uut.getColorTint('#F1BE0B', 3)).toEqual('#7E6715');
expect(uut.getColorTint('#F1BE0B', 95)).toEqual('#FEFDFB');
});
});

describe('generateColorPalette', () => {
const baseColor = '#3F88C5';
const tints = ['#193852', '#255379', '#316EA1', '#3F88C5', '#66A0D1', '#8DB9DD', '#B5D1E9', '#DCE9F4'];
const tints = ['#233748', '#2E5270', '#376E9B', '#3F88C5', '#6CA0CB', '#96B8D4', '#BED0E0', '#E1E9EF'];
const baseColorLight = '#DCE9F4';
const tintsLight = ['#1A3851', '#265278', '#326D9F', '#4187C3', '#68A0CF', '#8EB8DC', '#B5D1E8', '#DCE9F4'];
const saturationLevels = [-10, -10, -20, -20, -25, -25, -25, -25, -20, -10];
const tintsSaturationLevels = [
'#1E384D',
'#2D5271',
'#466C8C',
'#3F88C5',
'#7F9EB8',
'#A0B7CB',
'#C1D0DD',
'#E2E9EE'
];
// const tintsSaturationLevelsDarkest = ['#162837', '#223F58', '#385770', '#486E90', '#3F88C5', '#7C9CB6', '#9AB2C6', '#B7C9D7', '#D3DFE9', '#F0F5F9'];
// const tintsAddDarkestTints = ['#12283B', '#1C405E', '#275881', '#3270A5', '#3F88C5', '#629ED0', '#86B4DA', '#A9CAE5', '#CCDFF0', '#EFF5FA'];

it('should memoize calls for generateColorPalette', () => {
uut.getColorTint(baseColor, 20);
Expand Down Expand Up @@ -163,21 +150,6 @@ describe('style/Colors', () => {
expect(palette).toEqual(tintsLight);
});

it('should generateColorPalette with adjustSaturation option true and saturationLevels 8 array', () => {
const palette = uut.generateColorPalette(baseColor, {adjustSaturation: true, saturationLevels});
expect(palette.length).toBe(8);
expect(palette).toContain(baseColor); // adjusting baseColor tint as well
expect(palette).toEqual(tintsSaturationLevels);
});

// it('should generateColorPalette with adjustSaturation option true and saturationLevels 10 array and addDarkestTints true', () => {
// const options = {adjustSaturation: true, saturationLevels, addDarkestTints: true};
// const palette = uut.generateColorPalette(baseColor, options);
// expect(palette.length).toBe(10);
// expect(palette).toContain(baseColor); // adjusting baseColor tint as well
// expect(palette).toEqual(tintsSaturationLevelsDarkest);
// });

it('should generateColorPalette with avoidReverseOnDark option false not reverse on light mode (default)', () => {
const palette = uut.generateColorPalette(baseColor, {avoidReverseOnDark: false});
expect(palette.length).toBe(8);
Expand Down Expand Up @@ -205,6 +177,25 @@ describe('style/Colors', () => {
// expect(palette).toContain(baseColor);
// expect(palette).toEqual(tintsAddDarkestTints);
// });

it('should not apply saturation curve when base color saturation is below threshold', () => {
const lowSatColor = '#7A8A8A';
const rawPalette = ['#323939', '#4A5454', '#626F6F', '#7A8A8A', '#95A2A2', '#B0BABA', '#CBD2D2', '#E7EAEA'];
const palette = uut.generateColorPalette(lowSatColor);
expect(palette).toEqual(rawPalette);
});

it('should respect custom saturationFloor', () => {
const palette = uut.generateColorPalette(baseColor, {saturationFloor: 40});
const expected = ['#20374B', '#2E5270', '#376E9B', '#3F88C5', '#6CA0CB', '#96B8D4', '#BCD0E2', '#DFE9F1'];
expect(palette).toEqual(expected);
});

it('should not apply curve when adjustSaturation is false', () => {
const rawPalette = ['#193852', '#255379', '#316EA1', '#3F88C5', '#66A0D1', '#8DB9DD', '#B5D1E9', '#DCE9F4'];
const palette = uut.generateColorPalette(baseColor, {adjustSaturation: false});
expect(palette).toEqual(rawPalette);
});
});

describe('generateDesignTokens', () => {
Expand Down
79 changes: 35 additions & 44 deletions packages/react-native-ui-lib/src/style/colors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,15 @@ export type GetColorByHexOptions = {validColors?: string[]};
export type GeneratePaletteOptions = {
/** Whether to adjust the lightness of very light colors (generating darker palette) */
adjustLightness?: boolean;
/** Whether to adjust the saturation of colors with high lightness and saturation (unifying saturation level throughout palette) */
/** Whether to apply the saturation curve to unify saturation levels throughout the palette */
adjustSaturation?: boolean;
/** Array of saturation adjustments to apply on the color's tints array (from darkest to lightest).
* The 'adjustSaturation' option must be true */
saturationLevels?: number[];
/** Percentage-based saturation curve indexed by distance from base color.
* When provided, applies proportional saturation reduction outward from the base color */
saturationCurve?: number[];
/** Base saturation threshold below which the saturation curve is not applied (default: 50) */
saturationThreshold?: number;
/** Minimum saturation value when applying the curve (default: 20) */
saturationFloor?: number;
/** Whether to add two extra dark colors usually used for dark mode (generating a palette of 10 instead of 8 colors) */
addDarkestTints?: boolean; // TODO: rename 'fullPalette'
/** Whether to reverse the color palette to generate dark mode palette (pass 'true' to generate the same palette for both light and dark modes) */
Expand Down Expand Up @@ -268,7 +272,7 @@ export class Colors {
const end = options?.addDarkestTints && colorLightness > 10 ? undefined : size;
const sliced = tints.slice(start, end);

const adjusted = options?.adjustSaturation && adjustSaturation(sliced, color, options?.saturationLevels);
const adjusted = options?.adjustSaturation && adjustSaturationWithCurve(sliced, color, options);
return adjusted || sliced;
}, generatePaletteCacheResolver);

Expand All @@ -277,7 +281,9 @@ export class Colors {
adjustSaturation: true,
addDarkestTints: false,
avoidReverseOnDark: false,
saturationLevels: undefined
saturationCurve: [1.0, 0.89, 0.77, 0.65, 0.55, 0.47, 0.42, 0.38, 0.34, 0.30],
saturationThreshold: 50,
saturationFloor: 20
};

generateColorPalette = _.memoize((color: string, options?: GeneratePaletteOptions): string[] => {
Expand Down Expand Up @@ -354,50 +360,35 @@ function colorStringValue(color: string | object) {
return color?.toString();
}

function adjustAllSaturations(colors: string[], baseColor: string, levels: number[]) {
const array: string[] = [];
_.forEach(colors, (c, index) => {
if (c === baseColor) {
array[index] = baseColor;
} else {
const hsl = Color(c).hsl();
const saturation = hsl.color[1];
const level = levels[index];
if (level !== undefined) {
const saturationLevel = saturation + level;
const clampedLevel = _.clamp(saturationLevel, 0, 100);
const adjusted = setSaturation(c, clampedLevel);
array[index] = adjusted;
}
}
});
return array;
}
type CurveOptions = Pick<GeneratePaletteOptions, 'saturationCurve' | 'saturationThreshold' | 'saturationFloor'>;

function adjustSaturationWithCurve(colors: string[], baseColor: string, options?: CurveOptions): string[] | null {
const {saturationCurve: curve, saturationThreshold: threshold = 50, saturationFloor: floor = 20} = options ?? {};

function adjustSaturation(colors: string[], baseColor: string, levels?: number[]) {
if (levels) {
return adjustAllSaturations(colors, baseColor, levels);
if (!curve) {
return null;
}

let array;
const lightnessLevel = 80;
const saturationLevel = 60;
const hsl = Color(baseColor).hsl();
const lightness = Math.round(hsl.color[2]);
const baseSaturation = Color(baseColor).hsl().color[1];
if (baseSaturation <= threshold) {
return null;
}

if (lightness > lightnessLevel) {
const saturation = Math.round(hsl.color[1]);
if (saturation > saturationLevel) {
array = _.map(colors, e => (e !== baseColor ? setSaturation(e, saturationLevel) : e));
}
const baseIndex = colors.indexOf(baseColor.toUpperCase());
if (baseIndex === -1) {
return null;
}
return array;
}

function setSaturation(color: string, saturation: number): string {
const hsl = Color(color).hsl();
hsl.color[1] = saturation;
return hsl.hex();
return colors.map((hex, i) => {
if (i === baseIndex) {
return hex;
}
const hsl = Color(hex).hsl();
const distance = Math.abs(i - baseIndex);
const percentage = curve[Math.min(distance, curve.length - 1)];
const newSaturation = Math.max(floor, Math.ceil(baseSaturation * percentage));
return Color.hsl(hsl.color[0], newSaturation, hsl.color[2]).hex();
});
}

function generateColorTint(color: string, tintLevel: number): string {
Expand Down