@react-stately/color 3.0.0-beta.1 → 3.0.0-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Color.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
- import {clamp} from '@react-aria/utils';
13
+ import {clamp, toFixedNumber} from '@react-stately/utils';
14
14
  import {ColorChannel, ColorChannelRange, ColorFormat, Color as IColor} from '@react-types/color';
15
15
  // @ts-ignore
16
16
  import intlMessages from '../intl/*.json';
@@ -32,7 +32,7 @@ export function parseColor(value: string): IColor {
32
32
  abstract class Color implements IColor {
33
33
  abstract toFormat(format: ColorFormat): IColor;
34
34
  abstract toString(format: ColorFormat | 'css'): string;
35
- abstract clone(): Color;
35
+ abstract clone(): IColor;
36
36
  abstract getChannelRange(channel: ColorChannel): ColorChannelRange;
37
37
  abstract formatChannelValue(channel: ColorChannel, locale: string): string;
38
38
 
@@ -61,6 +61,8 @@ abstract class Color implements IColor {
61
61
  getChannelName(channel: ColorChannel, locale: string) {
62
62
  return messages.getStringForLocale(channel, locale);
63
63
  }
64
+
65
+ abstract getColorSpace(): ColorFormat
64
66
  }
65
67
 
66
68
  const HEX_REGEX = /^#(?:([0-9a-f]{3})|([0-9a-f]{6}))$/i;
@@ -90,7 +92,9 @@ class RGBColor extends Color {
90
92
  let b = parseInt(m[2][4] + m[2][5], 16);
91
93
  return new RGBColor(r, g, b, 1);
92
94
  }
93
- } if ((m = value.match(RGB_REGEX))) {
95
+ }
96
+
97
+ if ((m = value.match(RGB_REGEX))) {
94
98
  const [r, g, b, a] = (m[1] ?? m[2]).split(',').map(n => Number(n.trim()));
95
99
  return new RGBColor(clamp(r, 0, 255), clamp(g, 0, 255), clamp(b, 0, 255), clamp(a ?? 1, 0, 1));
96
100
  }
@@ -119,6 +123,12 @@ class RGBColor extends Color {
119
123
  case 'rgb':
120
124
  case 'rgba':
121
125
  return this;
126
+ case 'hsb':
127
+ case 'hsba':
128
+ return this.toHSB();
129
+ case 'hsl':
130
+ case 'hsla':
131
+ return this.toHSL();
122
132
  default:
123
133
  throw new Error('Unsupported color conversion: rgb -> ' + format);
124
134
  }
@@ -128,7 +138,89 @@ class RGBColor extends Color {
128
138
  return this.red << 16 | this.green << 8 | this.blue;
129
139
  }
130
140
 
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 {
132
224
  return new RGBColor(this.red, this.green, this.blue, this.alpha);
133
225
  }
134
226
 
@@ -162,6 +254,10 @@ class RGBColor extends Color {
162
254
  }
163
255
  return new NumberFormatter(locale, options).format(value);
164
256
  }
257
+
258
+ getColorSpace(): ColorFormat {
259
+ return 'rgb';
260
+ }
165
261
  }
166
262
 
167
263
  // X = <negative/positive number with/without decimal places>
@@ -187,10 +283,14 @@ class HSBColor extends Color {
187
283
  switch (format) {
188
284
  case 'css':
189
285
  return this.toHSL().toString('css');
286
+ case 'hex':
287
+ return this.toRGB().toString('hex');
288
+ case 'hexa':
289
+ return this.toRGB().toString('hexa');
190
290
  case 'hsb':
191
- return `hsb(${this.hue}, ${this.saturation}%, ${this.brightness}%)`;
291
+ return `hsb(${this.hue}, ${toFixedNumber(this.saturation, 2)}%, ${toFixedNumber(this.brightness, 2)}%)`;
192
292
  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})`;
194
294
  default:
195
295
  return this.toFormat(format).toString(format);
196
296
  }
@@ -204,29 +304,52 @@ class HSBColor extends Color {
204
304
  case 'hsl':
205
305
  case 'hsla':
206
306
  return this.toHSL();
307
+ case 'rgb':
308
+ case 'rgba':
309
+ return this.toRGB();
207
310
  default:
208
311
  throw new Error('Unsupported color conversion: hsb -> ' + format);
209
312
  }
210
313
  }
211
314
 
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
+ }
215
333
 
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 {
217
340
  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
+ );
227
350
  }
228
351
 
229
- clone(): Color {
352
+ clone(): IColor {
230
353
  return new HSBColor(this.hue, this.saturation, this.brightness, this.alpha);
231
354
  }
232
355
 
@@ -264,6 +387,10 @@ class HSBColor extends Color {
264
387
  }
265
388
  return new NumberFormatter(locale, options).format(value);
266
389
  }
390
+
391
+ getColorSpace(): ColorFormat {
392
+ return 'hsb';
393
+ }
267
394
  }
268
395
 
269
396
  // X = <negative/positive number with/without decimal places>
@@ -276,13 +403,11 @@ function mod(n, m) {
276
403
  return ((n % m) + m) % m;
277
404
  }
278
405
 
279
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
280
406
  class HSLColor extends Color {
281
407
  constructor(private hue: number, private saturation: number, private lightness: number, private alpha: number) {
282
408
  super();
283
409
  }
284
410
 
285
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
286
411
  static parse(value: string): HSLColor | void {
287
412
  let m: RegExpMatchArray | void;
288
413
  if ((m = value.match(HSL_REGEX))) {
@@ -293,27 +418,73 @@ class HSLColor extends Color {
293
418
 
294
419
  toString(format: ColorFormat | 'css') {
295
420
  switch (format) {
421
+ case 'hex':
422
+ return this.toRGB().toString('hex');
423
+ case 'hexa':
424
+ return this.toRGB().toString('hexa');
296
425
  case 'hsl':
297
- return `hsl(${this.hue}, ${this.saturation}%, ${this.lightness}%)`;
426
+ return `hsl(${this.hue}, ${toFixedNumber(this.saturation, 2)}%, ${toFixedNumber(this.lightness, 2)}%)`;
298
427
  case 'css':
299
428
  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})`;
301
430
  default:
302
431
  return this.toFormat(format).toString(format);
303
432
  }
304
433
  }
305
-
306
434
  toFormat(format: ColorFormat): IColor {
307
435
  switch (format) {
308
436
  case 'hsl':
309
437
  case 'hsla':
310
438
  return this;
439
+ case 'hsb':
440
+ case 'hsba':
441
+ return this.toHSB();
442
+ case 'rgb':
443
+ case 'rgba':
444
+ return this.toRGB();
311
445
  default:
312
446
  throw new Error('Unsupported color conversion: hsl -> ' + format);
313
447
  }
314
448
  }
315
449
 
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 {
317
488
  return new HSLColor(this.hue, this.saturation, this.lightness, this.alpha);
318
489
  }
319
490
 
@@ -351,4 +522,8 @@ class HSLColor extends Color {
351
522
  }
352
523
  return new NumberFormatter(locale, options).format(value);
353
524
  }
525
+
526
+ getColorSpace(): ColorFormat {
527
+ return 'hsl';
528
+ }
354
529
  }
@@ -14,7 +14,7 @@ import {Color, ColorFieldProps} from '@react-types/color';
14
14
  import {parseColor} from './Color';
15
15
  import {useColor} from './useColor';
16
16
  import {useControlledState} from '@react-stately/utils';
17
- import {useEffect, useState} from 'react';
17
+ import {useEffect, useMemo, useRef, useState} from 'react';
18
18
 
19
19
  export interface ColorFieldState {
20
20
  /**
@@ -41,7 +41,13 @@ export interface ColorFieldState {
41
41
  /** Sets the current value to the maximum color value, and fires `onChange`. */
42
42
  incrementToMax(): void,
43
43
  /** Sets the current value to the minimum color value, and fires `onChange`. */
44
- decrementToMin(): void
44
+ decrementToMin(): void,
45
+ /**
46
+ * Validates a user input string.
47
+ * Values can be partially entered, and may be valid even if they cannot currently be parsed to a color.
48
+ * Can be used to implement validation as a user types.
49
+ */
50
+ validate(value: string): boolean
45
51
  }
46
52
 
47
53
  const MIN_COLOR = parseColor('#000000');
@@ -66,59 +72,107 @@ export function useColorFieldState(
66
72
  let initialValue = useColor(value);
67
73
  let initialDefaultValue = useColor(defaultValue);
68
74
  let [colorValue, setColorValue] = useControlledState<Color>(initialValue, initialDefaultValue, onChange);
75
+ let [inputValue, setInputValue] = useState(() => (value || defaultValue) && colorValue ? colorValue.toString('hex') : '');
69
76
 
70
- let initialInputValue = (value || defaultValue) && colorValue ? colorValue.toString('hex') : '';
71
- let [inputValue, setInputValue] = useState(initialInputValue);
77
+ let safelySetColorValue = (newColor: Color | ((prevState: Color) => Color)) => {
78
+ if (typeof newColor === 'function') {
79
+ setColorValue((prev:Color) => {
80
+ let resolved: Color = newColor(prev);
81
+ if (!prev || !resolved) {
82
+ return resolved;
83
+ }
84
+ if (resolved.toHexInt() !== prev.toHexInt()) {
85
+ return resolved;
86
+ }
87
+ return prev;
88
+ });
89
+ return;
90
+ }
91
+ if (!colorValue || !newColor) {
92
+ setColorValue(newColor);
93
+ return;
94
+ }
95
+ if (newColor.toHexInt() !== colorValue.toHexInt()) {
96
+ setColorValue(newColor);
97
+ return;
98
+ }
99
+ };
72
100
 
73
101
  useEffect(() => {
74
- setInputValue(inputValue => {
75
- // Parse color from current inputValue.
76
- // Only update the input value if the parseColorValue is not equivalent.
77
- if (!inputValue.length && colorValue) { return colorValue.toString('hex'); }
78
- try {
79
- let currentColor = parseColor(inputValue.startsWith('#') ? inputValue : `#${inputValue}`);
80
- if (currentColor.toHexInt() !== colorValue?.toHexInt()) {
81
- return colorValue ? colorValue.toString('hex') : '';
82
- }
83
- } catch (err) {
84
- // ignore
102
+ setInputValue(colorValue ? colorValue.toString('hex') : '');
103
+ }, [colorValue, setInputValue]);
104
+
105
+ let parsedValue = useMemo(() => {
106
+ let color;
107
+ try {
108
+ color = parseColor(inputValue.startsWith('#') ? inputValue : `#${inputValue}`);
109
+ } catch (err) {
110
+ color = null;
111
+ }
112
+ return color;
113
+ }, [parseColor, inputValue]);
114
+ let parsed = useRef(null);
115
+ parsed.current = parsedValue;
116
+
117
+ let commit = () => {
118
+ // Set to empty state if input value is empty
119
+ if (!inputValue.length) {
120
+ safelySetColorValue(null);
121
+ setInputValue(value === undefined ? '' : colorValue.toString('hex'));
122
+ return;
123
+ }
124
+
125
+ // if it failed to parse, then reset input to formatted version of current number
126
+ if (parsed.current == null) {
127
+ setInputValue(colorValue ? colorValue.toString('hex') : '');
128
+ return;
129
+ }
130
+
131
+ safelySetColorValue(parsed.current);
132
+ // in a controlled state, the numberValue won't change, so we won't go back to our old input without help
133
+ let newColorValue = '';
134
+ if (colorValue) {
135
+ newColorValue = colorValue.toString('hex');
136
+ }
137
+ setInputValue(newColorValue);
138
+ };
139
+
140
+ let increment = () => {
141
+ safelySetColorValue((prevColor: Color) => {
142
+ let newValue = addColorValue(parsed.current, step);
143
+ // if we've arrived at the same value that was previously in the state, the
144
+ // input value should be updated to match
145
+ // ex type 4, press increment, highlight the number in the input, type 4 again, press increment
146
+ // you'd be at 5, then incrementing to 5 again, so no re-render would happen and 4 would be left in the input
147
+ if (newValue === prevColor) {
148
+ setInputValue(newValue.toString('hex'));
85
149
  }
86
- return inputValue;
150
+ return newValue;
87
151
  });
88
- }, [inputValue, colorValue, setInputValue]);
89
-
90
- let increment = () => setColorValue((prevColor: Color) => addColorValue(prevColor, step));
91
- let decrement = () => setColorValue((prevColor: Color) => addColorValue(prevColor, -step));
92
- let incrementToMax = () => setColorValue((prevColor: Color) => addColorValue(prevColor, MAX_COLOR_INT));
93
- let decrementToMin = () => setColorValue((prevColor: Color) => addColorValue(prevColor, -MAX_COLOR_INT));
94
-
95
- let setFieldInputValue = (value: string) => {
96
- value = value.match(/^#?[0-9a-f]{0,6}$/i)?.[0];
97
- if (value !== undefined) {
98
- if (!value.length && colorValue) {
99
- setColorValue(null);
100
- return;
101
- }
102
- try {
103
- let newColor = parseColor(value.startsWith('#') ? value : `#${value}`);
104
- setColorValue((prevColor: Color) => {
105
- setInputValue(value);
106
- return prevColor && prevColor.toHexInt() === newColor.toHexInt() ? prevColor : newColor;
107
- });
108
- } catch (err) {
109
- setInputValue(value);
152
+ };
153
+ let decrement = () => {
154
+ safelySetColorValue((prevColor: Color) => {
155
+ let newValue = addColorValue(parsed.current, -step);
156
+ // if we've arrived at the same value that was previously in the state, the
157
+ // input value should be updated to match
158
+ // ex type 4, press increment, highlight the number in the input, type 4 again, press increment
159
+ // you'd be at 5, then incrementing to 5 again, so no re-render would happen and 4 would be left in the input
160
+ if (newValue === prevColor) {
161
+ setInputValue(newValue.toString('hex'));
110
162
  }
111
- }
163
+ return newValue;
164
+ });
112
165
  };
166
+ let incrementToMax = () => safelySetColorValue(MAX_COLOR);
167
+ let decrementToMin = () => safelySetColorValue(MIN_COLOR);
113
168
 
114
- let commit = () => {
115
- setInputValue(colorValue ? colorValue.toString('hex') : '');
116
- };
169
+ let validate = (value: string) => value === '' || !!value.match(/^#?[0-9a-f]{0,6}$/i)?.[0];
117
170
 
118
171
  return {
172
+ validate,
119
173
  colorValue,
120
174
  inputValue,
121
- setInputValue: setFieldInputValue,
175
+ setInputValue,
122
176
  commit,
123
177
  increment,
124
178
  incrementToMax,
@@ -130,11 +184,10 @@ export function useColorFieldState(
130
184
  function addColorValue(color: Color, step: number) {
131
185
  let newColor = color ? color : MIN_COLOR;
132
186
  let colorInt = newColor.toHexInt();
133
- let newColorString = color ? color.toString('hex') : '';
134
187
 
135
188
  let clampInt = Math.min(Math.max(colorInt + step, MIN_COLOR_INT), MAX_COLOR_INT);
136
189
  if (clampInt !== colorInt) {
137
- newColorString = `#${clampInt.toString(16).padStart(6, '0').toUpperCase()}`;
190
+ let newColorString = `#${clampInt.toString(16).padStart(6, '0').toUpperCase()}`;
138
191
  newColor = parseColor(newColorString);
139
192
  }
140
193
  return newColor;
@@ -108,6 +108,7 @@ export function useColorWheelState(props: ColorWheelProps): ColorWheelState {
108
108
  valueRef.current = value;
109
109
 
110
110
  let [isDragging, setDragging] = useState(false);
111
+ let isDraggingRef = useRef(false).current;
111
112
 
112
113
  let hue = value.getChannelValue('hue');
113
114
  function setHue(v: number) {
@@ -117,7 +118,9 @@ export function useColorWheelState(props: ColorWheelProps): ColorWheelState {
117
118
  }
118
119
  v = roundToStep(mod(v, 360), step);
119
120
  if (hue !== v) {
120
- setValue(value.withChannelValue('hue', v));
121
+ let color = value.withChannelValue('hue', v);
122
+ valueRef.current = color;
123
+ setValue(color);
121
124
  }
122
125
  }
123
126
 
@@ -155,13 +158,14 @@ export function useColorWheelState(props: ColorWheelProps): ColorWheelState {
155
158
  }
156
159
  },
157
160
  setDragging(isDragging) {
158
- setDragging(wasDragging => {
159
- if (onChangeEnd && !isDragging && wasDragging) {
160
- onChangeEnd(valueRef.current);
161
- }
161
+ let wasDragging = isDraggingRef;
162
+ isDraggingRef = isDragging;
163
+
164
+ if (onChangeEnd && !isDragging && wasDragging) {
165
+ onChangeEnd(valueRef.current);
166
+ }
162
167
 
163
- return isDragging;
164
- });
168
+ setDragging(isDragging);
165
169
  },
166
170
  isDragging,
167
171
  getDisplayColor() {