@react-stately/color 3.0.0-alpha.0 → 3.0.0-beta.10

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.
@@ -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 {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');
@@ -57,68 +63,102 @@ export function useColorFieldState(
57
63
  props: ColorFieldProps
58
64
  ): ColorFieldState {
59
65
  let {
60
- step = 1,
61
66
  value,
62
67
  defaultValue,
63
68
  onChange
64
69
  } = props;
70
+ let {step} = MIN_COLOR.getChannelRange('red');
65
71
 
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);
72
-
73
- 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
85
- }
86
- return inputValue;
87
- });
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);
110
- }
77
+ let safelySetColorValue = (newColor: Color) => {
78
+ if (!colorValue || !newColor) {
79
+ setColorValue(newColor);
80
+ return;
81
+ }
82
+ if (newColor.toHexInt() !== colorValue.toHexInt()) {
83
+ setColorValue(newColor);
84
+ return;
111
85
  }
112
86
  };
113
87
 
114
- let commit = () => {
88
+ let prevValue = useRef(colorValue);
89
+ if (prevValue.current !== colorValue) {
115
90
  setInputValue(colorValue ? colorValue.toString('hex') : '');
91
+ prevValue.current = colorValue;
92
+ }
93
+
94
+
95
+ let parsedValue = useMemo(() => {
96
+ let color;
97
+ try {
98
+ color = parseColor(inputValue.startsWith('#') ? inputValue : `#${inputValue}`);
99
+ } catch (err) {
100
+ color = null;
101
+ }
102
+ return color;
103
+ }, [inputValue]);
104
+ let parsed = useRef(null);
105
+ parsed.current = parsedValue;
106
+
107
+ let commit = () => {
108
+ // Set to empty state if input value is empty
109
+ if (!inputValue.length) {
110
+ safelySetColorValue(null);
111
+ setInputValue(value === undefined ? '' : colorValue.toString('hex'));
112
+ return;
113
+ }
114
+
115
+ // if it failed to parse, then reset input to formatted version of current number
116
+ if (parsed.current == null) {
117
+ setInputValue(colorValue ? colorValue.toString('hex') : '');
118
+ return;
119
+ }
120
+
121
+ safelySetColorValue(parsed.current);
122
+ // in a controlled state, the numberValue won't change, so we won't go back to our old input without help
123
+ let newColorValue = '';
124
+ if (colorValue) {
125
+ newColorValue = colorValue.toString('hex');
126
+ }
127
+ setInputValue(newColorValue);
128
+ };
129
+
130
+ let increment = () => {
131
+ let newValue = addColorValue(parsed.current, step);
132
+ // if we've arrived at the same value that was previously in the state, the
133
+ // input value should be updated to match
134
+ // ex type 4, press increment, highlight the number in the input, type 4 again, press increment
135
+ // you'd be at 5, then incrementing to 5 again, so no re-render would happen and 4 would be left in the input
136
+ if (newValue === colorValue) {
137
+ setInputValue(newValue.toString('hex'));
138
+ }
139
+ safelySetColorValue(newValue);
116
140
  };
141
+ let decrement = () => {
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 === colorValue) {
148
+ setInputValue(newValue.toString('hex'));
149
+ }
150
+ safelySetColorValue(newValue);
151
+ };
152
+ let incrementToMax = () => safelySetColorValue(MAX_COLOR);
153
+ let decrementToMin = () => safelySetColorValue(MIN_COLOR);
154
+
155
+ let validate = (value: string) => value === '' || !!value.match(/^#?[0-9a-f]{0,6}$/i)?.[0];
117
156
 
118
157
  return {
158
+ validate,
119
159
  colorValue,
120
160
  inputValue,
121
- setInputValue: setFieldInputValue,
161
+ setInputValue,
122
162
  commit,
123
163
  increment,
124
164
  incrementToMax,
@@ -130,11 +170,10 @@ export function useColorFieldState(
130
170
  function addColorValue(color: Color, step: number) {
131
171
  let newColor = color ? color : MIN_COLOR;
132
172
  let colorInt = newColor.toHexInt();
133
- let newColorString = color ? color.toString('hex') : '';
134
173
 
135
174
  let clampInt = Math.min(Math.max(colorInt + step, MIN_COLOR_INT), MAX_COLOR_INT);
136
175
  if (clampInt !== colorInt) {
137
- newColorString = `#${clampInt.toString(16).padStart(6, '0').toUpperCase()}`;
176
+ let newColorString = `#${clampInt.toString(16).padStart(6, '0').toUpperCase()}`;
138
177
  newColor = parseColor(newColorString);
139
178
  }
140
179
  return newColor;
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import {Color, ColorSliderProps} from '@react-types/color';
14
- import {parseColor} from './Color';
14
+ import {normalizeColor, parseColor} from './Color';
15
15
  import {SliderState, useSliderState} from '@react-stately/slider';
16
16
  import {useControlledState} from '@react-stately/utils';
17
17
 
@@ -30,14 +30,6 @@ interface ColorSliderStateOptions extends ColorSliderProps {
30
30
  locale: string
31
31
  }
32
32
 
33
- function normalizeColor(v: string | Color) {
34
- if (typeof v === 'string') {
35
- return parseColor(v);
36
- } else {
37
- return v;
38
- }
39
- }
40
-
41
33
  /**
42
34
  * Provides state management for a color slider component.
43
35
  * Color sliders allow users to adjust an individual channel of a color value.
@@ -59,13 +51,14 @@ export function useColorSliderState(props: ColorSliderStateOptions): ColorSlider
59
51
  setColor(color.withChannelValue(channel, v));
60
52
  },
61
53
  onChangeEnd([v]) {
62
- // onChange will have already been called with the right value, this is just to trigger onChangEnd
54
+ // onChange will have already been called with the right value, this is just to trigger onChangeEnd
63
55
  if (props.onChangeEnd) {
64
56
  props.onChangeEnd(color.withChannelValue(channel, v));
65
57
  }
66
58
  }
67
59
  });
68
60
 
61
+ let {step, pageSize} = color.getChannelRange(channel);
69
62
  return {
70
63
  ...sliderState,
71
64
  value: color,
@@ -92,6 +85,8 @@ export function useColorSliderState(props: ColorSliderStateOptions): ColorSlider
92
85
  },
93
86
  getThumbValueLabel() {
94
87
  return color.formatChannelValue(channel, locale);
95
- }
88
+ },
89
+ step,
90
+ pageSize
96
91
  };
97
92
  }
@@ -11,14 +11,14 @@
11
11
  */
12
12
 
13
13
  import {Color, ColorWheelProps} from '@react-types/color';
14
- import {parseColor} from './Color';
14
+ import {normalizeColor, parseColor} from './Color';
15
15
  import {useControlledState} from '@react-stately/utils';
16
- import {useState} from 'react';
16
+ import {useRef, useState} from 'react';
17
17
 
18
18
  export interface ColorWheelState {
19
- /** The current color value displayed by the color wheel. */
19
+ /** The current color value represented by the color wheel. */
20
20
  readonly value: Color,
21
- /** Sets the color value displayed by the color wheel, and triggers `onChange`. */
21
+ /** Sets the color value represented by the color wheel, and triggers `onChange`. */
22
22
  setValue(value: string | Color): void,
23
23
 
24
24
  /** The current value of the hue channel displayed by the color wheel. */
@@ -32,22 +32,18 @@ export interface ColorWheelState {
32
32
  getThumbPosition(radius: number): {x: number, y: number},
33
33
 
34
34
  /** Increments the hue by the given amount (defaults to 1). */
35
- increment(minStepSize?: number): void,
35
+ increment(stepSize?: number): void,
36
36
  /** Decrements the hue by the given amount (defaults to 1). */
37
- decrement(minStepSize?: number): void,
37
+ decrement(stepSize?: number): void,
38
38
 
39
- /** Whether the cxolor wheel is currently being dragged. */
39
+ /** Whether the color wheel is currently being dragged. */
40
40
  readonly isDragging: boolean,
41
41
  /** Sets whether the color wheel is being dragged. */
42
- setDragging(value: boolean): void
43
- }
44
-
45
- function normalizeColor(v: string | Color) {
46
- if (typeof v === 'string') {
47
- return parseColor(v);
48
- } else {
49
- return v;
50
- }
42
+ setDragging(value: boolean): void,
43
+ /** Returns the color that should be displayed in the color wheel instead of `value`. */
44
+ getDisplayColor(): Color,
45
+ step: number,
46
+ pageStep: number
51
47
  }
52
48
 
53
49
  const DEFAULT_COLOR = parseColor('hsl(0, 100%, 50%)');
@@ -95,14 +91,20 @@ function cartesianToAngle(x: number, y: number, radius: number): number {
95
91
  * Color wheels allow users to adjust the hue of an HSL or HSB color value on a circular track.
96
92
  */
97
93
  export function useColorWheelState(props: ColorWheelProps): ColorWheelState {
98
- let {defaultValue, onChange, step = 1} = props;
94
+ let {defaultValue, onChange, onChangeEnd} = props;
99
95
 
100
96
  if (!props.value && !defaultValue) {
101
97
  defaultValue = DEFAULT_COLOR;
102
98
  }
103
99
 
104
100
  let [value, setValue] = useControlledState(normalizeColor(props.value), normalizeColor(defaultValue), onChange);
101
+ let valueRef = useRef(value);
102
+ valueRef.current = value;
103
+
104
+ let channelRange = value.getChannelRange('hue');
105
+ let {minValue: minValueX, maxValue: maxValueX, step: step, pageSize: pageStep} = channelRange;
105
106
  let [isDragging, setDragging] = useState(false);
107
+ let isDraggingRef = useRef(false).current;
106
108
 
107
109
  let hue = value.getChannelValue('hue');
108
110
  function setHue(v: number) {
@@ -112,14 +114,20 @@ export function useColorWheelState(props: ColorWheelProps): ColorWheelState {
112
114
  }
113
115
  v = roundToStep(mod(v, 360), step);
114
116
  if (hue !== v) {
115
- setValue(value.withChannelValue('hue', v));
117
+ let color = value.withChannelValue('hue', v);
118
+ valueRef.current = color;
119
+ setValue(color);
116
120
  }
117
121
  }
118
122
 
119
123
  return {
120
124
  value,
125
+ step,
126
+ pageStep,
121
127
  setValue(v) {
122
- setValue(normalizeColor(v));
128
+ let color = normalizeColor(v);
129
+ valueRef.current = color;
130
+ setValue(color);
123
131
  },
124
132
  hue,
125
133
  setHue,
@@ -129,27 +137,38 @@ export function useColorWheelState(props: ColorWheelProps): ColorWheelState {
129
137
  getThumbPosition(radius) {
130
138
  return angleToCartesian(value.getChannelValue('hue'), radius);
131
139
  },
132
- increment(minStepSize: number = 0) {
133
- let newValue = hue + Math.max(minStepSize, step);
134
- if (newValue > 360) {
140
+ increment(stepSize = 1) {
141
+ let s = Math.max(stepSize, step);
142
+ let newValue = hue + s;
143
+ if (newValue >= maxValueX) {
135
144
  // Make sure you can always get back to 0.
136
- newValue = 0;
145
+ newValue = minValueX;
137
146
  }
138
- setHue(newValue);
147
+ setHue(roundToStep(mod(newValue, 360), s));
139
148
  },
140
- decrement(minStepSize: number = 0) {
141
- let s = Math.max(minStepSize, step);
149
+ decrement(stepSize = 1) {
150
+ let s = Math.max(stepSize, step);
142
151
  if (hue === 0) {
143
152
  // We can't just subtract step because this might be the case:
144
153
  // |(previous step) - 0| < step size
145
154
  setHue(roundDown(360 / s) * s);
146
155
  } else {
147
- setHue(hue - s);
156
+ setHue(roundToStep(mod(hue - s, 360), s));
148
157
  }
149
158
  },
150
- setDragging(value) {
151
- setDragging(value);
159
+ setDragging(isDragging) {
160
+ let wasDragging = isDraggingRef;
161
+ isDraggingRef = isDragging;
162
+
163
+ if (onChangeEnd && !isDragging && wasDragging) {
164
+ onChangeEnd(valueRef.current);
165
+ }
166
+
167
+ setDragging(isDragging);
152
168
  },
153
- isDragging
169
+ isDragging,
170
+ getDisplayColor() {
171
+ return value.toFormat('hsl').withChannelValue('saturation', 100).withChannelValue('lightness', 50);
172
+ }
154
173
  };
155
174
  }