Skip to content

Commit 8dc33dc

Browse files
authored
Add conversions to colors (#2496)
* Add conversions to colors * Add better tests for color * update notices and ignore statements * review comments * fix hsb conversions * fix ts * improve accuracy * add comment * easier to read converted values * remove wikipedia math forumlas from notices * fix some types * fix remaining types
1 parent 37a9f15 commit 8dc33dc

File tree

6 files changed

+343
-27
lines changed

6 files changed

+343
-27
lines changed

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,11 +104,13 @@
104104
"chalk": "^4.1.2",
105105
"chromatic": "^5.4.0",
106106
"clsx": "^1.1.1",
107+
"color-space": "^1.16.0",
107108
"concurrently": "^6.0.2",
108109
"core-js": "^3.0.0",
109110
"cross-env": "^7.0.2",
110111
"cross-spawn": "^7.0.3",
111112
"css-loader": "^2.1.1",
113+
"delta-e": "^0.0.8",
112114
"eslint": "^7.10.0",
113115
"eslint-plugin-import": "^2.22.1",
114116
"eslint-plugin-jest": "^24.0.2",
@@ -118,6 +120,7 @@
118120
"eslint-plugin-react": "^7.21.2",
119121
"eslint-plugin-react-hooks": "^4.1.2",
120122
"eslint-plugin-rulesdir": "^0.1.0",
123+
"fast-check": "^2.19.0",
121124
"fast-glob": "^3.1.0",
122125
"file-loader": "^0.9.0",
123126
"fs-extra": "^10.0.0",

packages/@react-stately/color/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
},
1919
"dependencies": {
2020
"@babel/runtime": "^7.6.2",
21-
"@react-aria/utils": "^3.9.0",
2221
"@react-stately/slider": "^3.0.3",
2322
"@react-stately/utils": "^3.2.2",
2423
"@react-types/color": "3.0.0-beta.3",

packages/@react-stately/color/src/Color.ts

Lines changed: 201 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {clamp} from '@react-aria/utils';
13+
import {clamp, toFixedNumber} from '@react-stately/utils';
1414
import {ColorChannel, ColorChannelRange, ColorFormat, Color as IColor} from '@react-types/color';
1515
// @ts-ignore
1616
import intlMessages from '../intl/*.json';
@@ -32,7 +32,7 @@ export function parseColor(value: string): IColor {
3232
abstract class Color implements IColor {
3333
abstract toFormat(format: ColorFormat): IColor;
3434
abstract toString(format: ColorFormat | 'css'): string;
35-
abstract clone(): Color;
35+
abstract clone(): IColor;
3636
abstract getChannelRange(channel: ColorChannel): ColorChannelRange;
3737
abstract formatChannelValue(channel: ColorChannel, locale: string): string;
3838

@@ -61,6 +61,8 @@ abstract class Color implements IColor {
6161
getChannelName(channel: ColorChannel, locale: string) {
6262
return messages.getStringForLocale(channel, locale);
6363
}
64+
65+
abstract getColorSpace(): ColorFormat
6466
}
6567

6668
const HEX_REGEX = /^#(?:([0-9a-f]{3})|([0-9a-f]{6}))$/i;
@@ -90,7 +92,9 @@ class RGBColor extends Color {
9092
let b = parseInt(m[2][4] + m[2][5], 16);
9193
return new RGBColor(r, g, b, 1);
9294
}
93-
} if ((m = value.match(RGB_REGEX))) {
95+
}
96+
97+
if ((m = value.match(RGB_REGEX))) {
9498
const [r, g, b, a] = (m[1] ?? m[2]).split(',').map(n => Number(n.trim()));
9599
return new RGBColor(clamp(r, 0, 255), clamp(g, 0, 255), clamp(b, 0, 255), clamp(a ?? 1, 0, 1));
96100
}
@@ -119,6 +123,12 @@ class RGBColor extends Color {
119123
case 'rgb':
120124
case 'rgba':
121125
return this;
126+
case 'hsb':
127+
case 'hsba':
128+
return this.toHSB();
129+
case 'hsl':
130+
case 'hsla':
131+
return this.toHSL();
122132
default:
123133
throw new Error('Unsupported color conversion: rgb -> ' + format);
124134
}
@@ -128,7 +138,89 @@ class RGBColor extends Color {
128138
return this.red << 16 | this.green << 8 | this.blue;
129139
}
130140

131-
clone(): Color {
141+
/**
142+
* Converts an RGB color value to HSB.
143+
* Conversion formula adapted from https://en.wikipedia.org/wiki/HSL_and_HSV#From_RGB.
144+
* @returns An HSBColor object.
145+
*/
146+
private toHSB(): IColor {
147+
const red = this.red / 255;
148+
const green = this.green / 255;
149+
const blue = this.blue / 255;
150+
const min = Math.min(red, green, blue);
151+
const brightness = Math.max(red, green, blue);
152+
const chroma = brightness - min;
153+
const saturation = brightness === 0 ? 0 : chroma / brightness;
154+
let hue = 0; // achromatic
155+
156+
if (chroma !== 0) {
157+
switch (brightness) {
158+
case red:
159+
hue = (green - blue) / chroma + (green < blue ? 6 : 0);
160+
break;
161+
case green:
162+
hue = (blue - red) / chroma + 2;
163+
break;
164+
case blue:
165+
hue = (red - green) / chroma + 4;
166+
break;
167+
}
168+
169+
hue /= 6;
170+
}
171+
172+
return new HSBColor(
173+
toFixedNumber(hue * 360, 2),
174+
toFixedNumber(saturation * 100, 2),
175+
toFixedNumber(brightness * 100, 2),
176+
this.alpha
177+
);
178+
}
179+
180+
/**
181+
* Converts an RGB color value to HSL.
182+
* Conversion formula adapted from https://en.wikipedia.org/wiki/HSL_and_HSV#From_RGB.
183+
* @returns An HSLColor object.
184+
*/
185+
private toHSL(): IColor {
186+
const red = this.red / 255;
187+
const green = this.green / 255;
188+
const blue = this.blue / 255;
189+
const min = Math.min(red, green, blue);
190+
const max = Math.max(red, green, blue);
191+
const lightness = (max + min) / 2;
192+
const chroma = max - min;
193+
let hue: number;
194+
let saturation: number;
195+
196+
if (chroma === 0) {
197+
hue = saturation = 0; // achromatic
198+
} else {
199+
saturation = chroma / (lightness < .5 ? max + min : 2 - max - min);
200+
201+
switch (max) {
202+
case red:
203+
hue = (green - blue) / chroma + (green < blue ? 6 : 0);
204+
break;
205+
case green:
206+
hue = (blue - red) / chroma + 2;
207+
break;
208+
case blue:
209+
hue = (red - green) / chroma + 4;
210+
break;
211+
}
212+
213+
hue /= 6;
214+
}
215+
216+
return new HSLColor(
217+
toFixedNumber(hue * 360, 2),
218+
toFixedNumber(saturation * 100, 2),
219+
toFixedNumber(lightness * 100, 2),
220+
this.alpha);
221+
}
222+
223+
clone(): IColor {
132224
return new RGBColor(this.red, this.green, this.blue, this.alpha);
133225
}
134226

@@ -162,6 +254,10 @@ class RGBColor extends Color {
162254
}
163255
return new NumberFormatter(locale, options).format(value);
164256
}
257+
258+
getColorSpace(): ColorFormat {
259+
return 'rgb';
260+
}
165261
}
166262

167263
// X = <negative/positive number with/without decimal places>
@@ -187,10 +283,14 @@ class HSBColor extends Color {
187283
switch (format) {
188284
case 'css':
189285
return this.toHSL().toString('css');
286+
case 'hex':
287+
return this.toRGB().toString('hex');
288+
case 'hexa':
289+
return this.toRGB().toString('hexa');
190290
case 'hsb':
191-
return `hsb(${this.hue}, ${this.saturation}%, ${this.brightness}%)`;
291+
return `hsb(${this.hue}, ${toFixedNumber(this.saturation, 2)}%, ${toFixedNumber(this.brightness, 2)}%)`;
192292
case 'hsba':
193-
return `hsba(${this.hue}, ${this.saturation}%, ${this.brightness}%, ${this.alpha})`;
293+
return `hsba(${this.hue}, ${toFixedNumber(this.saturation, 2)}%, ${toFixedNumber(this.brightness, 2)}%, ${this.alpha})`;
194294
default:
195295
return this.toFormat(format).toString(format);
196296
}
@@ -204,29 +304,52 @@ class HSBColor extends Color {
204304
case 'hsl':
205305
case 'hsla':
206306
return this.toHSL();
307+
case 'rgb':
308+
case 'rgba':
309+
return this.toRGB();
207310
default:
208311
throw new Error('Unsupported color conversion: hsb -> ' + format);
209312
}
210313
}
211314

212-
private toHSL(): Color {
213-
// determine the lightness in the range [0,100]
214-
var l = (2 - this.saturation / 100) * this.brightness / 2;
315+
/**
316+
* Converts a HSB color to HSL.
317+
* Conversion formula adapted from https://en.wikipedia.org/wiki/HSL_and_HSV#HSV_to_HSL.
318+
* @returns An HSLColor object.
319+
*/
320+
private toHSL(): IColor {
321+
let saturation = this.saturation / 100;
322+
let brightness = this.brightness / 100;
323+
let lightness = brightness * (1 - saturation / 2);
324+
saturation = lightness === 0 || lightness === 1 ? 0 : (brightness - lightness) / Math.min(lightness, 1 - lightness);
325+
326+
return new HSLColor(
327+
toFixedNumber(this.hue, 2),
328+
toFixedNumber(saturation * 100, 2),
329+
toFixedNumber(lightness * 100, 2),
330+
this.alpha
331+
);
332+
}
215333

216-
// store the HSL components
334+
/**
335+
* Converts a HSV color value to RGB.
336+
* Conversion formula adapted from https://en.wikipedia.org/wiki/HSL_and_HSV#HSV_to_RGB_alternative.
337+
* @returns An RGBColor object.
338+
*/
339+
private toRGB(): IColor {
217340
let hue = this.hue;
218-
let saturation = this.saturation * this.brightness / (l < 50 ? l * 2 : 200 - l * 2);
219-
let lightness = l;
220-
221-
// correct a division-by-zero error
222-
if (isNaN(saturation)) {
223-
saturation = 0;
224-
}
225-
226-
return new HSLColor(hue, saturation, lightness, this.alpha);
341+
let saturation = this.saturation / 100;
342+
let brightness = this.brightness / 100;
343+
let fn = (n: number, k = (n + hue / 60) % 6) => brightness - saturation * brightness * Math.max(Math.min(k, 4 - k, 1), 0);
344+
return new RGBColor(
345+
Math.round(fn(5) * 255),
346+
Math.round(fn(3) * 255),
347+
Math.round(fn(1) * 255),
348+
this.alpha
349+
);
227350
}
228351

229-
clone(): Color {
352+
clone(): IColor {
230353
return new HSBColor(this.hue, this.saturation, this.brightness, this.alpha);
231354
}
232355

@@ -264,6 +387,10 @@ class HSBColor extends Color {
264387
}
265388
return new NumberFormatter(locale, options).format(value);
266389
}
390+
391+
getColorSpace(): ColorFormat {
392+
return 'hsb';
393+
}
267394
}
268395

269396
// X = <negative/positive number with/without decimal places>
@@ -276,13 +403,11 @@ function mod(n, m) {
276403
return ((n % m) + m) % m;
277404
}
278405

279-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
280406
class HSLColor extends Color {
281407
constructor(private hue: number, private saturation: number, private lightness: number, private alpha: number) {
282408
super();
283409
}
284410

285-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
286411
static parse(value: string): HSLColor | void {
287412
let m: RegExpMatchArray | void;
288413
if ((m = value.match(HSL_REGEX))) {
@@ -293,27 +418,73 @@ class HSLColor extends Color {
293418

294419
toString(format: ColorFormat | 'css') {
295420
switch (format) {
421+
case 'hex':
422+
return this.toRGB().toString('hex');
423+
case 'hexa':
424+
return this.toRGB().toString('hexa');
296425
case 'hsl':
297-
return `hsl(${this.hue}, ${this.saturation}%, ${this.lightness}%)`;
426+
return `hsl(${this.hue}, ${toFixedNumber(this.saturation, 2)}%, ${toFixedNumber(this.lightness, 2)}%)`;
298427
case 'css':
299428
case 'hsla':
300-
return `hsla(${this.hue}, ${this.saturation}%, ${this.lightness}%, ${this.alpha})`;
429+
return `hsla(${this.hue}, ${toFixedNumber(this.saturation, 2)}%, ${toFixedNumber(this.lightness, 2)}%, ${this.alpha})`;
301430
default:
302431
return this.toFormat(format).toString(format);
303432
}
304433
}
305-
306434
toFormat(format: ColorFormat): IColor {
307435
switch (format) {
308436
case 'hsl':
309437
case 'hsla':
310438
return this;
439+
case 'hsb':
440+
case 'hsba':
441+
return this.toHSB();
442+
case 'rgb':
443+
case 'rgba':
444+
return this.toRGB();
311445
default:
312446
throw new Error('Unsupported color conversion: hsl -> ' + format);
313447
}
314448
}
315449

316-
clone(): Color {
450+
/**
451+
* Converts a HSL color to HSB.
452+
* Conversion formula adapted from https://en.wikipedia.org/wiki/HSL_and_HSV#HSL_to_HSV.
453+
* @returns An HSBColor object.
454+
*/
455+
private toHSB(): IColor {
456+
let saturation = this.saturation / 100;
457+
let lightness = this.lightness / 100;
458+
let brightness = lightness + saturation * Math.min(lightness, 1 - lightness);
459+
saturation = brightness === 0 ? 0 : 2 * (1 - lightness / brightness);
460+
return new HSBColor(
461+
toFixedNumber(this.hue, 2),
462+
toFixedNumber(saturation * 100, 2),
463+
toFixedNumber(brightness * 100, 2),
464+
this.alpha
465+
);
466+
}
467+
468+
/**
469+
* Converts a HSL color to RGB.
470+
* Conversion formula adapted from https://en.wikipedia.org/wiki/HSL_and_HSV#HSL_to_RGB_alternative.
471+
* @returns An RGBColor object.
472+
*/
473+
private toRGB(): IColor {
474+
let hue = this.hue;
475+
let saturation = this.saturation / 100;
476+
let lightness = this.lightness / 100;
477+
let a = saturation * Math.min(lightness, 1 - lightness);
478+
let fn = (n: number, k = (n + hue / 30) % 12) => lightness - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
479+
return new RGBColor(
480+
Math.round(fn(0) * 255),
481+
Math.round(fn(8) * 255),
482+
Math.round(fn(4) * 255),
483+
this.alpha
484+
);
485+
}
486+
487+
clone(): IColor {
317488
return new HSLColor(this.hue, this.saturation, this.lightness, this.alpha);
318489
}
319490

@@ -351,4 +522,8 @@ class HSLColor extends Color {
351522
}
352523
return new NumberFormatter(locale, options).format(value);
353524
}
525+
526+
getColorSpace(): ColorFormat {
527+
return 'hsl';
528+
}
354529
}

0 commit comments

Comments
 (0)