@react-stately/color
Version:
Spectrum UI components in React
191 lines (173 loc) • 6.67 kB
text/typescript
/*
* Copyright 2020 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import {Color, ColorFieldProps} from '@react-types/color';
import {FormValidationState, useFormValidationState} from '@react-stately/form';
import {parseColor} from './Color';
import {useColor} from './useColor';
import {useControlledState} from '@react-stately/utils';
import {useMemo, useState} from 'react';
export interface ColorFieldState extends FormValidationState {
/**
* The current text value of the input. Updated as the user types,
* and formatted according to `formatOptions` on blur.
*/
readonly inputValue: string,
/**
* The currently parsed color value, or null if the field is empty.
* Updated based on the `inputValue` as the user types.
*/
readonly colorValue: Color | null,
/** Sets the current text value of the input. */
setInputValue(value: string): void,
/**
* Updates the input value based on the currently parsed color value.
* Typically this is called when the field is blurred.
*/
commit(): void,
/** Increments the current input value to the next step boundary, and fires `onChange`. */
increment(): void,
/** Decrements the current input value to the next step boundary, and fires `onChange`. */
decrement(): void,
/** Sets the current value to the maximum color value, and fires `onChange`. */
incrementToMax(): void,
/** Sets the current value to the minimum color value, and fires `onChange`. */
decrementToMin(): void,
/**
* Validates a user input string.
* Values can be partially entered, and may be valid even if they cannot currently be parsed to a color.
* Can be used to implement validation as a user types.
*/
validate(value: string): boolean
}
const MIN_COLOR = parseColor('#000000');
const MAX_COLOR = parseColor('#FFFFFF');
const MIN_COLOR_INT = MIN_COLOR.toHexInt();
const MAX_COLOR_INT = MAX_COLOR.toHexInt();
/**
* Provides state management for a color field component. Color fields allow
* users to enter and adjust a hex color value.
*/
export function useColorFieldState(
props: ColorFieldProps
): ColorFieldState {
let {
value,
defaultValue,
onChange
} = props;
let {step} = MIN_COLOR.getChannelRange('red');
let initialValue = useColor(value);
let initialDefaultValue = useColor(defaultValue);
let [colorValue, setColorValue] = useControlledState<Color | null>(initialValue!, initialDefaultValue!, onChange);
let [inputValue, setInputValue] = useState(() => (value || defaultValue) && colorValue ? colorValue.toString('hex') : '');
let validation = useFormValidationState({
...props,
value: colorValue
});
let safelySetColorValue = (newColor: Color | null) => {
if (!colorValue || !newColor) {
setColorValue(newColor);
return;
}
if (newColor.toHexInt() !== colorValue.toHexInt()) {
setColorValue(newColor);
return;
}
};
let [prevValue, setPrevValue] = useState(colorValue);
if (prevValue !== colorValue) {
setInputValue(colorValue ? colorValue.toString('hex') : '');
setPrevValue(colorValue);
}
let parsedValue = useMemo(() => {
let color;
try {
color = parseColor(inputValue.startsWith('#') ? inputValue : `#${inputValue}`);
} catch (err) {
color = null;
}
return color;
}, [inputValue]);
let commit = () => {
// Set to empty state if input value is empty
if (!inputValue.length) {
safelySetColorValue(null);
if (value === undefined || colorValue === null) {
setInputValue('');
} else {
setInputValue(colorValue.toString('hex'));
}
return;
}
// if it failed to parse, then reset input to formatted version of current number
if (parsedValue == null) {
setInputValue(colorValue ? colorValue.toString('hex') : '');
return;
}
safelySetColorValue(parsedValue);
// in a controlled state, the numberValue won't change, so we won't go back to our old input without help
let newColorValue = '';
if (colorValue) {
newColorValue = colorValue.toString('hex');
}
setInputValue(newColorValue);
};
let increment = () => {
let newValue = addColorValue(parsedValue, step);
// if we've arrived at the same value that was previously in the state, the
// input value should be updated to match
// ex type 4, press increment, highlight the number in the input, type 4 again, press increment
// you'd be at 5, then incrementing to 5 again, so no re-render would happen and 4 would be left in the input
if (newValue === colorValue) {
setInputValue(newValue.toString('hex'));
}
safelySetColorValue(newValue);
validation.commitValidation();
};
let decrement = () => {
let newValue = addColorValue(parsedValue, -step);
// if we've arrived at the same value that was previously in the state, the
// input value should be updated to match
// ex type 4, press increment, highlight the number in the input, type 4 again, press increment
// you'd be at 5, then incrementing to 5 again, so no re-render would happen and 4 would be left in the input
if (newValue === colorValue) {
setInputValue(newValue.toString('hex'));
}
safelySetColorValue(newValue);
validation.commitValidation();
};
let incrementToMax = () => safelySetColorValue(MAX_COLOR);
let decrementToMin = () => safelySetColorValue(MIN_COLOR);
let validate = (value: string) => value === '' || !!value.match(/^#?[0-9a-f]{0,6}$/i)?.[0];
return {
...validation,
validate,
colorValue,
inputValue,
setInputValue,
commit,
increment,
incrementToMax,
decrement,
decrementToMin
};
}
function addColorValue(color: Color, step: number) {
let newColor = color ? color : MIN_COLOR;
let colorInt = newColor.toHexInt();
let clampInt = Math.min(Math.max(colorInt + step, MIN_COLOR_INT), MAX_COLOR_INT);
if (clampInt !== colorInt) {
let newColorString = `#${clampInt.toString(16).padStart(6, '0').toUpperCase()}`;
newColor = parseColor(newColorString);
}
return newColor;
}