Skip to content

Commit e1ce079

Browse files
committed
Use CSS color-mix functionality to calculate color scales
This allows completely removing the chroma.js dependency.
1 parent e4d01b0 commit e1ce079

File tree

8 files changed

+87
-75
lines changed

8 files changed

+87
-75
lines changed

package.json

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "react-activity-calendar",
3-
"version": "2.5.3",
3+
"version": "2.6.0",
44
"description": "React component to display activity data in calendar",
55
"author": "Jonathan Gruber <gruberjonathan@gmail.com>",
66
"license": "MIT",
@@ -28,8 +28,6 @@
2828
"test": "jest"
2929
},
3030
"dependencies": {
31-
"@types/chroma-js": "^2.4.3",
32-
"chroma-js": "^3.1.1",
3331
"date-fns": "^4.1.0"
3432
},
3533
"devDependencies": {
@@ -41,6 +39,8 @@
4139
"@emotion/styled": "^11.11.5",
4240
"@eslint/compat": "^1.1.0",
4341
"@eslint/js": "^9.11.1",
42+
"@jest/globals": "^29.7.0",
43+
"@jest/types": "^29.6.3",
4444
"@mui/material": "^6.1.1",
4545
"@rollup/plugin-babel": "^6.0.4",
4646
"@rollup/plugin-commonjs": "^28.0.0",
@@ -55,7 +55,6 @@
5555
"@storybook/theming": "^8.3.3",
5656
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
5757
"@types/eslint__js": "^8.42.3",
58-
"@types/jest": "^29.5.13",
5958
"@types/react": "^18.3.9",
6059
"@types/react-dom": "^18.3.0",
6160
"@types/react-syntax-highlighter": "^15.5.13",

pnpm-lock.yaml

Lines changed: 6 additions & 27 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/component/ActivityCalendar.tsx

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
'use client';
22

3-
import chroma from 'chroma-js';
43
import { getYear, parseISO } from 'date-fns';
54
import {
65
type CSSProperties,
@@ -17,7 +16,6 @@ import styles from '../styles/styles.module.css';
1716
import type {
1817
Activity,
1918
BlockElement,
20-
Color,
2119
DayIndex,
2220
DayName,
2321
EventHandlerMap,
@@ -392,15 +390,14 @@ const ActivityCalendar = forwardRef<HTMLElement, Props>(
392390

393391
const { width, height } = getDimensions();
394392

395-
const zeroColor = colorScale[0] as Color;
396393
const containerStyles = {
397394
fontSize,
398395
...(useAnimation && {
399-
[`--${NAMESPACE}-loading`]: zeroColor,
396+
[`--${NAMESPACE}-loading`]: colorScale[0],
400397
[`--${NAMESPACE}-loading-active`]:
401398
colorScheme === 'light'
402-
? chroma(zeroColor).darken(0.3).hex()
403-
: chroma(zeroColor).brighten(0.25).hex(),
399+
? `oklab(from ${colorScale[0]} calc(l * 0.96) a b)`
400+
: `oklab(from ${colorScale[0]} calc(l * 1.06) a b)`,
404401
}),
405402
};
406403

src/types.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,12 @@ export interface Theme {
4242
// Require that at least one color scheme is passed.
4343
export type ThemeInput =
4444
| {
45-
light: ColorScale | [from: Color, to: Color];
46-
dark?: ColorScale | [from: Color, to: Color];
45+
light: ColorScale;
46+
dark?: ColorScale;
4747
}
4848
| {
49-
light?: ColorScale | [from: Color, to: Color];
50-
dark: ColorScale | [from: Color, to: Color];
49+
light?: ColorScale;
50+
dark: ColorScale;
5151
};
5252

5353
interface BlockAttributes extends SVGAttributes<SVGRectElement>, HTMLAttributes<SVGRectElement> {}

src/utils/calendar.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { describe, expect, it } from '@jest/globals';
2+
13
import type { Activity } from '../types';
24
import { validateActivities } from './calendar';
35

src/utils/label.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { describe, expect, it } from '@jest/globals';
2+
13
import type { DayIndex, Week } from '../types';
24
import { generateTestData, groupByWeeks } from './calendar';
35
import { getMonthLabels, initWeekdayLabels } from './label';
@@ -100,10 +102,10 @@ describe('initWeekdayLabels', () => {
100102
[4, [true, false, true, false, false, true, false]],
101103
[5, [false, true, false, true, false, false, true]],
102104
[6, [true, false, true, false, true, false, false]],
103-
] as const)(
105+
])(
104106
'should return true for every second weekday for `true` as input with %d as week start',
105107
(weekStart, expected) => {
106-
const actual = initWeekdayLabels(true, weekStart);
108+
const actual = initWeekdayLabels(true, weekStart as DayIndex);
107109

108110
expect(actual.shouldShow).toBe(true);
109111
for (const weekStart of days) {

src/utils/theme.test.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,39 @@
1+
import { beforeEach, describe, expect, it } from '@jest/globals';
2+
13
import type { Theme, ThemeInput } from '../types';
24
import { createTheme } from './theme';
35

46
describe('createTheme', () => {
57
const defaultTheme = {
6-
light: ['#ebebeb', '#bdbdbd', '#929292', '#696969', '#424242'],
7-
dark: ['#333333', '#5c5c5c', '#898989', '#b9b9b9', '#ebebeb'],
8+
light: [
9+
'color-mix(in oklab, hsl(0, 0%, 26%) 0%, hsl(0, 0%, 92%))',
10+
'color-mix(in oklab, hsl(0, 0%, 26%) 25%, hsl(0, 0%, 92%))',
11+
'color-mix(in oklab, hsl(0, 0%, 26%) 50%, hsl(0, 0%, 92%))',
12+
'color-mix(in oklab, hsl(0, 0%, 26%) 75%, hsl(0, 0%, 92%))',
13+
'color-mix(in oklab, hsl(0, 0%, 26%) 100%, hsl(0, 0%, 92%))',
14+
],
15+
dark: [
16+
'color-mix(in oklab, hsl(0, 0%, 92%) 0%, hsl(0, 0%, 22%))',
17+
'color-mix(in oklab, hsl(0, 0%, 92%) 25%, hsl(0, 0%, 22%))',
18+
'color-mix(in oklab, hsl(0, 0%, 92%) 50%, hsl(0, 0%, 22%))',
19+
'color-mix(in oklab, hsl(0, 0%, 92%) 75%, hsl(0, 0%, 22%))',
20+
'color-mix(in oklab, hsl(0, 0%, 92%) 100%, hsl(0, 0%, 22%))',
21+
],
822
};
923

1024
const explicitTheme: Theme = {
1125
light: ['#f0f0f0', '#c4edde', '#7ac7c4', '#f73859', '#384259'],
1226
dark: ['hsl(0, 0%, 22%)', '#4D455D', '#7DB9B6', '#F5E9CF', '#E96479'],
1327
};
1428

29+
beforeEach(() => {
30+
global.CSS = {
31+
// @ts-expect-error No clue how to fix this
32+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
33+
supports: (_k, _v) => true,
34+
};
35+
});
36+
1537
it('returns the default theme if no input is passed', () => {
1638
expect(createTheme()).toStrictEqual(defaultTheme);
1739
});
@@ -53,7 +75,7 @@ describe('createTheme', () => {
5375
createTheme({
5476
light: explicitTheme.light,
5577
}),
56-
).toStrictEqual<Theme>({
78+
).toStrictEqual({
5779
light: explicitTheme.light,
5880
dark: defaultTheme.dark,
5981
});
@@ -64,13 +86,18 @@ describe('createTheme', () => {
6486
createTheme({
6587
dark: explicitTheme.dark,
6688
}),
67-
).toStrictEqual<Theme>({
89+
).toStrictEqual({
6890
light: defaultTheme.light,
6991
dark: explicitTheme.dark,
7092
});
7193
});
7294

7395
it('throws if an invalid color is passed', () => {
96+
global.CSS = {
97+
// @ts-expect-error No clue how to fix this
98+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
99+
supports: (_k, _v) => false,
100+
};
74101
expect(() =>
75102
createTheme({
76103
dark: ['#333', '🙃'],

src/utils/theme.ts

Lines changed: 34 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,72 @@
1-
import chroma from 'chroma-js';
2-
31
import type { Color, ColorScale, Theme, ThemeInput } from '../types';
2+
import { range } from './calendar';
43

5-
export function createTheme(input?: ThemeInput, size: number = 5): Theme {
6-
const defaultTheme = createDefaultTheme(size);
4+
export function createTheme(input?: ThemeInput, steps: number = 5): Theme {
5+
const defaultTheme = createDefaultTheme(steps);
76

87
if (input) {
9-
validateTheme(input, size);
8+
validateInput(input, steps);
109

1110
input.light = input.light ?? defaultTheme.light;
1211
input.dark = input.dark ?? defaultTheme.dark;
1312

1413
return {
15-
light: isColorScale(input.light, size) ? input.light : createColorScale(input.light, size),
16-
dark: isColorScale(input.dark, size) ? input.dark : createColorScale(input.dark, size),
14+
light: isPair(input.light) ? calcColorScale(input.light, steps) : input.light,
15+
dark: isPair(input.dark) ? calcColorScale(input.dark, steps) : input.dark,
1716
};
1817
}
1918

2019
return defaultTheme;
2120
}
2221

23-
function createDefaultTheme(size: number): Theme {
22+
function createDefaultTheme(steps: number): Theme {
2423
return {
25-
light: createColorScale(['hsl(0, 0%, 92%)', 'hsl(0, 0%, 26%)'], size),
26-
dark: createColorScale(['hsl(0, 0%, 20%)', 'hsl(0, 0%, 92%)'], size),
24+
light: calcColorScale(['hsl(0, 0%, 92%)', 'hsl(0, 0%, 26%)'], steps),
25+
dark: calcColorScale(['hsl(0, 0%, 22%)', 'hsl(0, 0%, 92%)'], steps),
2726
};
2827
}
2928

30-
function validateTheme(input: ThemeInput, size: number) {
29+
function validateInput(input: ThemeInput, steps: number) {
3130
if (typeof input !== 'object' || (input.light === undefined && input.dark === undefined)) {
3231
throw new Error(
33-
`The theme object must contain at least one of the fields "light" and "dark" with exactly 2 or ${size} colors respectively.`,
32+
`The theme object must contain at least one of the fields "light" and "dark" with exactly 2 or ${steps} colors respectively.`,
3433
);
3534
}
3635

3736
if (input.light) {
3837
const { length } = input.light;
39-
if (length !== 2 && length !== size) {
40-
throw new Error(`theme.light must contain exactly 2 or ${size} colors, ${length} passed.`);
38+
if (length !== 2 && length !== steps) {
39+
throw new Error(`theme.light must contain exactly 2 or ${steps} colors, ${length} passed.`);
40+
}
41+
42+
for (const c of input.light) {
43+
if (!CSS.supports('color', c)) {
44+
throw new Error(`Invalid color "${String(c)}" passed. All CSS color formats are accepted.`);
45+
}
4146
}
4247
}
4348

4449
if (input.dark) {
4550
const { length } = input.dark;
46-
if (length !== 2 && length !== size) {
47-
throw new Error(`theme.dark must contain exactly 2 or ${size} colors, ${length} passed.`);
51+
if (length !== 2 && length !== steps) {
52+
throw new Error(`theme.dark must contain exactly 2 or ${steps} colors, ${length} passed.`);
4853
}
49-
}
50-
}
5154

52-
function isColorScale(colors: Array<unknown>, size: number): colors is ColorScale {
53-
const invalidColor = colors.find(color => !chroma.valid(color));
54-
55-
if (invalidColor) {
56-
throw new Error(
57-
`Invalid color "${String(invalidColor)}" passed. All CSS color formats are accepted.`,
58-
);
55+
for (const c of input.dark) {
56+
if (!CSS.supports('color', c)) {
57+
throw new Error(`Invalid color "${String(c)}" passed. All CSS color formats are accepted.`);
58+
}
59+
}
5960
}
61+
}
6062

61-
return colors.length === size;
63+
function calcColorScale(colors: [from: Color, to: Color], steps: number): ColorScale {
64+
return range(steps).map(i => {
65+
const mixFactor = (i / (steps - 1)) * 100;
66+
return `color-mix(in oklab, ${colors[1]} ${parseFloat(mixFactor.toFixed(2))}%, ${colors[0]})`;
67+
});
6268
}
6369

64-
function createColorScale(colors: [from: Color, to: Color], size: number): ColorScale {
65-
return chroma.scale(colors).mode('lch').colors(size);
70+
function isPair<T>(val: T[]): val is [T, T] {
71+
return val.length === 2;
6672
}

0 commit comments

Comments
 (0)