@react-stately/color 3.0.0-beta.0 → 3.0.0-beta.11

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,7 +11,7 @@
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
16
  import {useRef, useState} from 'react';
17
17
 
@@ -32,24 +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
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
42
  setDragging(value: boolean): void,
43
43
  /** Returns the color that should be displayed in the color wheel instead of `value`. */
44
- getDisplayColor(): Color
45
- }
46
-
47
- function normalizeColor(v: string | Color) {
48
- if (typeof v === 'string') {
49
- return parseColor(v);
50
- } else {
51
- return v;
52
- }
44
+ getDisplayColor(): Color,
45
+ step: number,
46
+ pageStep: number
53
47
  }
54
48
 
55
49
  const DEFAULT_COLOR = parseColor('hsl(0, 100%, 50%)');
@@ -97,7 +91,7 @@ function cartesianToAngle(x: number, y: number, radius: number): number {
97
91
  * Color wheels allow users to adjust the hue of an HSL or HSB color value on a circular track.
98
92
  */
99
93
  export function useColorWheelState(props: ColorWheelProps): ColorWheelState {
100
- let {defaultValue, onChange, onChangeEnd, step = 1} = props;
94
+ let {defaultValue, onChange, onChangeEnd} = props;
101
95
 
102
96
  if (!props.value && !defaultValue) {
103
97
  defaultValue = DEFAULT_COLOR;
@@ -107,7 +101,10 @@ export function useColorWheelState(props: ColorWheelProps): ColorWheelState {
107
101
  let valueRef = useRef(value);
108
102
  valueRef.current = value;
109
103
 
104
+ let channelRange = value.getChannelRange('hue');
105
+ let {minValue: minValueX, maxValue: maxValueX, step: step, pageSize: pageStep} = channelRange;
110
106
  let [isDragging, setDragging] = useState(false);
107
+ let isDraggingRef = useRef(false).current;
111
108
 
112
109
  let hue = value.getChannelValue('hue');
113
110
  function setHue(v: number) {
@@ -117,12 +114,16 @@ export function useColorWheelState(props: ColorWheelProps): ColorWheelState {
117
114
  }
118
115
  v = roundToStep(mod(v, 360), step);
119
116
  if (hue !== v) {
120
- setValue(value.withChannelValue('hue', v));
117
+ let color = value.withChannelValue('hue', v);
118
+ valueRef.current = color;
119
+ setValue(color);
121
120
  }
122
121
  }
123
122
 
124
123
  return {
125
124
  value,
125
+ step,
126
+ pageStep,
126
127
  setValue(v) {
127
128
  let color = normalizeColor(v);
128
129
  valueRef.current = color;
@@ -136,36 +137,38 @@ export function useColorWheelState(props: ColorWheelProps): ColorWheelState {
136
137
  getThumbPosition(radius) {
137
138
  return angleToCartesian(value.getChannelValue('hue'), radius);
138
139
  },
139
- increment(minStepSize: number = 0) {
140
- let newValue = hue + Math.max(minStepSize, step);
141
- if (newValue > 360) {
140
+ increment(stepSize = 1) {
141
+ let s = Math.max(stepSize, step);
142
+ let newValue = hue + s;
143
+ if (newValue >= maxValueX) {
142
144
  // Make sure you can always get back to 0.
143
- newValue = 0;
145
+ newValue = minValueX;
144
146
  }
145
- setHue(newValue);
147
+ setHue(roundToStep(mod(newValue, 360), s));
146
148
  },
147
- decrement(minStepSize: number = 0) {
148
- let s = Math.max(minStepSize, step);
149
+ decrement(stepSize = 1) {
150
+ let s = Math.max(stepSize, step);
149
151
  if (hue === 0) {
150
152
  // We can't just subtract step because this might be the case:
151
153
  // |(previous step) - 0| < step size
152
154
  setHue(roundDown(360 / s) * s);
153
155
  } else {
154
- setHue(hue - s);
156
+ setHue(roundToStep(mod(hue - s, 360), s));
155
157
  }
156
158
  },
157
159
  setDragging(isDragging) {
158
- setDragging(wasDragging => {
159
- if (onChangeEnd && !isDragging && wasDragging) {
160
- onChangeEnd(valueRef.current);
161
- }
160
+ let wasDragging = isDraggingRef;
161
+ isDraggingRef = isDragging;
162
+
163
+ if (onChangeEnd && !isDragging && wasDragging) {
164
+ onChangeEnd(valueRef.current);
165
+ }
162
166
 
163
- return isDragging;
164
- });
167
+ setDragging(isDragging);
165
168
  },
166
169
  isDragging,
167
170
  getDisplayColor() {
168
- return value.withChannelValue('saturation', 100).withChannelValue('lightness', 50);
171
+ return value.toFormat('hsl').withChannelValue('saturation', 100).withChannelValue('lightness', 50);
169
172
  }
170
173
  };
171
174
  }