From b63490a7aefb973ef049efb098701efa6013f411 Mon Sep 17 00:00:00 2001 From: Michael Jordan Date: Wed, 8 Dec 2021 10:00:40 -0500 Subject: [PATCH 1/5] ColorArea: add HSB and HSL support --- .../@react-aria/color/src/useColorArea.ts | 28 +++- .../@react-spectrum/color/src/ColorArea.tsx | 82 ++++++++++ .../color/stories/ColorArea.stories.tsx | 144 +++++++++++++++--- .../color/src/useColorAreaState.ts | 116 +++++++++++--- .../color/src/useColorWheelState.ts | 2 +- packages/@react-types/color/src/index.d.ts | 8 +- 6 files changed, 332 insertions(+), 48 deletions(-) diff --git a/packages/@react-aria/color/src/useColorArea.ts b/packages/@react-aria/color/src/useColorArea.ts index db31620e441..0080f7723bc 100644 --- a/packages/@react-aria/color/src/useColorArea.ts +++ b/packages/@react-aria/color/src/useColorArea.ts @@ -298,11 +298,29 @@ export function useColorArea(props: AriaColorAreaProps, state: ColorAreaState, i let colorAriaLabellingProps = useLabels(props); - let getValueTitle = () => [ - formatMessage('colorNameAndValue', {name: state.value.getChannelName('red', locale), value: state.value.formatChannelValue('red', locale)}), - formatMessage('colorNameAndValue', {name: state.value.getChannelName('green', locale), value: state.value.formatChannelValue('green', locale)}), - formatMessage('colorNameAndValue', {name: state.value.getChannelName('blue', locale), value: state.value.formatChannelValue('blue', locale)}) - ].join(', '); + let getValueTitle = () => { + switch (state.value.getColorSpace()) { + case 'hsb': + return [ + formatMessage('colorNameAndValue', {name: state.value.getChannelName('hue', locale), value: state.value.formatChannelValue('hue', locale)}), + formatMessage('colorNameAndValue', {name: state.value.getChannelName('saturation', locale), value: state.value.formatChannelValue('saturation', locale)}), + formatMessage('colorNameAndValue', {name: state.value.getChannelName('brightness', locale), value: state.value.formatChannelValue('brightness', locale)}) + ].join(', '); + case 'hsl': + return [ + formatMessage('colorNameAndValue', {name: state.value.getChannelName('hue', locale), value: state.value.formatChannelValue('hue', locale)}), + formatMessage('colorNameAndValue', {name: state.value.getChannelName('saturation', locale), value: state.value.formatChannelValue('saturation', locale)}), + formatMessage('colorNameAndValue', {name: state.value.getChannelName('lightness', locale), value: state.value.formatChannelValue('lightness', locale)}) + ].join(', '); + case 'rgb': + return [ + formatMessage('colorNameAndValue', {name: state.value.getChannelName('red', locale), value: state.value.formatChannelValue('red', locale)}), + formatMessage('colorNameAndValue', {name: state.value.getChannelName('green', locale), value: state.value.formatChannelValue('green', locale)}), + formatMessage('colorNameAndValue', {name: state.value.getChannelName('blue', locale), value: state.value.formatChannelValue('blue', locale)}) + ].join(', '); + } + return null; + }; let ariaRoleDescription = isMobile ? null : formatMessage('twoDimensionalSlider'); diff --git a/packages/@react-spectrum/color/src/ColorArea.tsx b/packages/@react-spectrum/color/src/ColorArea.tsx index b649967cfa8..91a32b2bd2e 100644 --- a/packages/@react-spectrum/color/src/ColorArea.tsx +++ b/packages/@react-spectrum/color/src/ColorArea.tsx @@ -109,6 +109,10 @@ function useGradients({direction, state, zChannel, xChannel, isDisabled}): Gradi let dir = false; let background = {colorAreaStyles: {}, gradientStyles: {}}; let zValue = state.value.getChannelValue(zChannel); + let {minValue: zMin, maxValue: zMax} = state.value.getChannelRange(zChannel); + let alphaValue = (zValue - zMin) / (zMax - zMin); + let lValue; + let isHSL = state.value.getColorSpace() === 'hsl'; let maskImage; if (!isDisabled) { switch (zChannel) { @@ -166,6 +170,84 @@ function useGradients({direction, state, zChannel, xChannel, isDisabled}): Gradi }; break; } + case 'hue': { + dir = xChannel !== 'saturation'; + lValue = isHSL ? 50 : 100; + background.gradientStyles = { + background: [ + (isHSL + /* For HSL, foreground gradient represents lightness, + from black to transparent to white. */ + ? `linear-gradient(to ${orientation[Number(dir)]}, hsla(0,0%,0%,1) 0%, hsla(0,0%,0%,0) 50%, hsla(0,0%,100%,0) 50%, hsla(0,0%,100%,1) 100%)` + /* For HSB, foreground gradient represents brightness, + from black to transparent. */ + : `linear-gradient(to ${orientation[Number(dir)]},hsl(0,0%,0%),hsla(0,0%,0%,0))`), + /* background gradient represents saturation, + from gray to transparent for HSL, + or from white to transparent for HSB. */ + `linear-gradient(to ${orientation[Number(!dir)]},hsl(0,0%,${lValue}%),hsla(0,0%,${lValue}%,0))`, + /* background color is the hue at full saturation and brightness */ + `hsl(${zValue}, 100%, 50%)` + ].join(',') + }; + break; + } + case 'saturation': { + dir = xChannel === 'hue'; + background.gradientStyles = { + background: [ + (isHSL + /* for HSL, foreground gradient represents lightness, + from black to transparent to white, with alpha set to saturation value */ + ? `linear-gradient(to ${orientation[Number(!dir)]}, hsla(0,0%,0%,${alphaValue}) 0%, hsla(0,0%,0%,0) 50%, hsla(0,0%,100%,0) 50%, hsla(0,0%,100%,${alphaValue}) 100%)` + /* for HSB, foreground gradient represents brightness, + from black to transparent, with alpha set to saturation value */ + : `linear-gradient(to ${orientation[Number(!dir)]},hsla(0,0%,0%,${alphaValue}),hsla(0,0%,0%,0))`), + /* background gradient represents the hue, + from 0 to 360, with alpha set to saturation value */ + `linear-gradient(to ${orientation[Number(dir)]},hsla(0,100%,50%,${alphaValue}),hsla(60,100%,50%,${alphaValue}),hsla(120,100%,50%,${alphaValue}),hsla(180,100%,50%,${alphaValue}),hsla(240,100%,50%,${alphaValue}),hsla(300,100%,50%,${alphaValue}),hsla(359,100%,50%,${alphaValue}))`, + (isHSL + /* for HSL, the alpha transparency representing saturation + of the gradients above overlay a solid gray background */ + ? 'hsl(0, 0%, 50%)' + /* for HSB, the alpha transparency representing saturation, + of the gradients above overlay a gradient from black to white */ + : `linear-gradient(to ${orientation[Number(!dir)]},hsl(0,0%,0%),hsl(0,0%,100%))`) + ].join(',') + }; + break; + } + case 'brightness': { + dir = xChannel === 'hue'; + background.gradientStyles = { + background: [ + /* foreground gradient represents saturation, + from white to transparent, with alpha set to brightness value */ + `linear-gradient(to ${orientation[Number(!dir)]},hsla(0,0%,100%,${alphaValue}),hsla(0,0%,100%,0))`, + /* background gradient represents the hue, + from 0 to 360, with alpha set to brightness value */ + `linear-gradient(to ${orientation[Number(dir)]},hsla(0,100%,50%,${alphaValue}),hsla(60,100%,50%,${alphaValue}),hsla(120,100%,50%,${alphaValue}),hsla(180,100%,50%,${alphaValue}),hsla(240,100%,50%,${alphaValue}),hsla(300,100%,50%,${alphaValue}),hsla(359,100%,50%,${alphaValue}))`, + /* for HSB, the alpha transparency representing brightness + of the gradients above overlay a solid black background */ + '#000' + ].join(',') + }; + break; + } + case 'lightness': { + dir = xChannel === 'hue'; + background.gradientStyles = { + backgroundImage: [ + /* foreground gradient represents the color saturation from 0 to 100, + adjusted by the lightness value */ + `linear-gradient(to ${orientation[Number(!dir)]},hsl(0,0%,${zValue}%),hsla(0,0%,${zValue}%,0))`, + /* background gradient represents the hue, from 0 to 360, + adjusted by the lightness value */ + `linear-gradient(to ${orientation[Number(dir)]},hsl(0,100%,${zValue}%),hsl(60,100%,${zValue}%),hsl(120,100%,${zValue}%),hsl(180,100%,${zValue}%),hsl(240,100%,${zValue}%),hsl(300,100%,${zValue}%),hsl(360,100%,${zValue}%))` + ].join(',') + }; + break; + } } } diff --git a/packages/@react-spectrum/color/stories/ColorArea.stories.tsx b/packages/@react-spectrum/color/stories/ColorArea.stories.tsx index a1e70542bff..3417c83e402 100644 --- a/packages/@react-spectrum/color/stories/ColorArea.stories.tsx +++ b/packages/@react-spectrum/color/stories/ColorArea.stories.tsx @@ -11,7 +11,7 @@ */ import {action} from '@storybook/addon-actions'; -import {ColorArea, ColorSlider} from '../'; +import {ColorArea, ColorSlider, ColorWheel} from '../'; import {ColorChannel, SpectrumColorAreaProps} from '@react-types/color'; import {Flex} from '@adobe/react-spectrum'; import {Meta, Story} from '@storybook/react'; @@ -32,17 +32,32 @@ const Template: Story = (args) => ( ); let RGB: Set = new Set(['red', 'green', 'blue']); +let HSL: Set = new Set(['hue', 'saturation', 'lightness']); +let HSB: Set = new Set(['hue', 'saturation', 'brightness']); let difference = (a, b): Set => new Set([...a].filter(x => !b.has(x))); function ColorAreaExample(props: SpectrumColorAreaProps) { let {xChannel, yChannel, isDisabled} = props; + let defaultValue = typeof props.defaultValue === 'string' ? parseColor(props.defaultValue) : props.defaultValue; + let [color, setColor] = useState(defaultValue || parseColor('#ff00ff')); let channels = new Set([xChannel, yChannel]); - let zChannel: ColorChannel = difference(RGB, channels).keys().next().value; - let [color, setColor] = useState(props.defaultValue || parseColor('#ff00ff')); - return (
+ let colorSpace = color.getColorSpace(); + let colorSpaceSet = RGB; + switch (colorSpace) { + case 'hsl': + colorSpaceSet = HSL; + break; + case 'hsb': + colorSpaceSet = HSB; + break; + } + let zChannel: ColorChannel = difference(colorSpaceSet, channels).keys().next().value; + let isHue = zChannel === 'hue'; + return (
- + { @@ -51,17 +66,34 @@ function ColorAreaExample(props: SpectrumColorAreaProps) { } setColor(e); }} /> - { - if (props.onChange) { - props.onChange(e); - } - setColor(e); - }} - onChangeEnd={props.onChangeEnd} - channel={zChannel} - isDisabled={isDisabled} /> + {isHue ? ( + { + if (props.onChange) { + props.onChange(e); + } + setColor(e); + }} + onChangeEnd={props.onChangeEnd} + isDisabled={isDisabled} + size={'size-2400'} + UNSAFE_style={{ + marginTop: 'calc( -.75 * var(--spectrum-global-dimension-size-2400))' + }} /> + ) : ( + { + if (props.onChange) { + props.onChange(e); + } + setColor(e); + }} + onChangeEnd={props.onChangeEnd} + channel={zChannel} + isDisabled={isDisabled} /> + )}
@@ -84,7 +116,7 @@ XBlueYRed.storyName = 'RGB xChannel="blue", yChannel="red"'; XBlueYRed.args = {...XBlueYGreen.args, xChannel: 'blue', yChannel: 'red'}; export let XRedYBlue = Template.bind({}); -XRedYBlue.storyName = 'GB xChannel="red", yChannel="blue"'; +XRedYBlue.storyName = 'RGB xChannel="red", yChannel="blue"'; XRedYBlue.args = {...XBlueYGreen.args, xChannel: 'red', yChannel: 'blue'}; export let XRedYGreen = Template.bind({}); @@ -120,3 +152,81 @@ XBlueYGreenSize3000.args = {...XBlueYGreen.args, size: 'size-3000'}; export let XBlueYGreenSize600 = Template.bind({}); XBlueYGreenSize600.storyName = 'RGB xChannel="blue", yChannel="green", size="size-600"'; XBlueYGreenSize600.args = {...XBlueYGreen.args, size: 'size-600'}; + +export let XSaturationYLightness = Template.bind({}); +XSaturationYLightness.storyName = 'HSL xChannel="saturation", yChannel="lightness"'; +XSaturationYLightness.args = {xChannel: 'saturation', yChannel: 'lightness', defaultValue: 'hsl(0, 100%, 50%)', onChange: action('onChange')}; + +export let XLightnessYSaturation = Template.bind({}); +XLightnessYSaturation.storyName = 'HSL xChannel="lightness", yChannel="saturation"'; +XLightnessYSaturation.args = {xChannel: 'lightness', yChannel: 'saturation', defaultValue: 'hsl(0, 100%, 50%)', onChange: action('onChange')}; + +/* TODO: what does a disabled color area look like? */ +export let XSaturationYLightnessisDisabled = Template.bind({}); +XSaturationYLightnessisDisabled.storyName = 'HSL xChannel="saturation", yChannel="lightness", isDisabled'; +XSaturationYLightnessisDisabled.args = {...XSaturationYLightness.args, isDisabled: true}; + +export let XHueYSaturationHSL = Template.bind({}); +XHueYSaturationHSL.storyName = 'HSL xChannel="hue", yChannel="saturation"'; +XHueYSaturationHSL.args = {xChannel: 'hue', yChannel: 'saturation', defaultValue: 'hsl(0, 100%, 50%)', onChange: action('onChange')}; + +export let XSaturationYHueHSL = Template.bind({}); +XSaturationYHueHSL.storyName = 'HSL xChannel="saturation", yChannel="hue"'; +XSaturationYHueHSL.args = {xChannel: 'saturation', yChannel: 'hue', defaultValue: 'hsl(0, 100%, 50%)', onChange: action('onChange')}; + +/* TODO: what does a disabled color area look like? */ +export let XHueYSaturationHSLisDisabled = Template.bind({}); +XHueYSaturationHSLisDisabled.storyName = 'HSL xChannel="hue", yChannel="saturation", isDisabled'; +XHueYSaturationHSLisDisabled.args = {...XHueYSaturationHSL.args, isDisabled: true}; + +export let XHueYLightnessHSL = Template.bind({}); +XHueYLightnessHSL.storyName = 'HSL xChannel="hue", yChannel="lightness"'; +XHueYLightnessHSL.args = {xChannel: 'hue', yChannel: 'lightness', defaultValue: 'hsl(0, 100%, 50%)', onChange: action('onChange')}; + +export let XLightnessYHueHSL = Template.bind({}); +XLightnessYHueHSL.storyName = 'HSL xChannel="lightness", yChannel="hue"'; +XLightnessYHueHSL.args = {xChannel: 'lightness', yChannel: 'hue', defaultValue: 'hsl(0, 100%, 50%)', onChange: action('onChange')}; + +/* TODO: what does a disabled color area look like? */ +export let XHueYLightnessHSLisDisabled = Template.bind({}); +XHueYLightnessHSLisDisabled.storyName = 'HSL xChannel="hue", yChannel="lightness", isDisabled'; +XHueYLightnessHSLisDisabled.args = {...XHueYLightnessHSL.args, isDisabled: true}; + +export let XSaturationYBrightness = Template.bind({}); +XSaturationYBrightness.storyName = 'HSB xChannel="saturation", yChannel="brightness"'; +XSaturationYBrightness.args = {xChannel: 'saturation', yChannel: 'brightness', defaultValue: 'hsb(0, 100%, 100%)', onChange: action('onChange')}; + +export let XBrightnessYSaturation = Template.bind({}); +XBrightnessYSaturation.storyName = 'HSB xChannel="brightness", yChannel="saturation"'; +XBrightnessYSaturation.args = {xChannel: 'brightness', yChannel: 'saturation', defaultValue: 'hsb(0, 100%, 100%)', onChange: action('onChange')}; + +/* TODO: what does a disabled color area look like? */ +export let XSaturationYBrightnessisDisabled = Template.bind({}); +XSaturationYBrightnessisDisabled.storyName = 'HSB xChannel="saturation", yChannel="brightness", isDisabled'; +XSaturationYBrightnessisDisabled.args = {...XSaturationYBrightness.args, isDisabled: true}; + +export let XHueYSaturationHSB = Template.bind({}); +XHueYSaturationHSB.storyName = 'HSB xChannel="hue", yChannel="saturation"'; +XHueYSaturationHSB.args = {xChannel: 'hue', yChannel: 'saturation', defaultValue: 'hsb(0, 100%, 100%)', onChange: action('onChange')}; + +export let XSaturationYHueHSB = Template.bind({}); +XSaturationYHueHSB.storyName = 'HSB xChannel="saturation", yChannel="hue"'; +XSaturationYHueHSB.args = {xChannel: 'saturation', yChannel: 'hue', defaultValue: 'hsb(0, 100%, 100%)', onChange: action('onChange')}; + +/* TODO: what does a disabled color area look like? */ +export let XHueYSaturationHSBisDisabled = Template.bind({}); +XHueYSaturationHSBisDisabled.storyName = 'HSB xChannel="hue", yChannel="saturation", isDisabled'; +XHueYSaturationHSBisDisabled.args = {...XHueYSaturationHSB.args, isDisabled: true}; + +export let XHueYBrightnessHSB = Template.bind({}); +XHueYBrightnessHSB.storyName = 'HSB xChannel="hue", yChannel="brightness"'; +XHueYBrightnessHSB.args = {xChannel: 'hue', yChannel: 'brightness', defaultValue: 'hsb(0, 100%, 100%)', onChange: action('onChange')}; + +export let XBrightnessYHueHSB = Template.bind({}); +XBrightnessYHueHSB.storyName = 'HSB xChannel="brightness", yChannel="hue"'; +XBrightnessYHueHSB.args = {xChannel: 'brightness', yChannel: 'hue', defaultValue: 'hsb(0, 100%, 100%)', onChange: action('onChange')}; + +/* TODO: what does a disabled color area look like? */ +export let XBrightnessYHueHSBisDisabled = Template.bind({}); +XBrightnessYHueHSBisDisabled.storyName = 'HSB xChannel="brightness", yChannel="hue", isDisabled'; +XBrightnessYHueHSBisDisabled.args = {...XBrightnessYHueHSB.args, isDisabled: true}; diff --git a/packages/@react-stately/color/src/useColorAreaState.ts b/packages/@react-stately/color/src/useColorAreaState.ts index 1a82f98ea73..383412283cd 100644 --- a/packages/@react-stately/color/src/useColorAreaState.ts +++ b/packages/@react-stately/color/src/useColorAreaState.ts @@ -65,6 +65,8 @@ export interface ColorAreaState { const DEFAULT_COLOR = parseColor('#ffffff'); const RGBSet: Set = new Set(['red', 'green', 'blue']); +const HSLSet: Set = new Set(['hue', 'saturation', 'lightness']); +const HSBSet: Set = new Set(['hue', 'saturation', 'brightness']); let difference = (a: Set, b: Set): Set => new Set([...a].filter(x => !b.has(x))); /** @@ -84,34 +86,100 @@ export function useColorAreaState(props: ColorAreaProps): ColorAreaState { valueRef.current = color; let channels = useMemo(() => { - if (!xChannel) { - switch (yChannel) { - case 'red': - case 'green': - xChannel = 'blue'; - break; - case 'blue': - xChannel = 'red'; - break; - default: - xChannel = 'blue'; - yChannel = 'green'; + // determine the color space from the color value + let colorSpace = valueRef.current.getColorSpace(); + let colorSpaceSet = RGBSet; + + if (colorSpace === 'hsb') { + colorSpaceSet = HSBSet; + if (!xChannel) { + switch (yChannel) { + case 'hue': + // eslint-disable-next-line react-hooks/exhaustive-deps + xChannel = 'brightness'; + break; + case 'brightness': + xChannel = 'saturation'; + break; + default: + xChannel = 'saturation'; + // eslint-disable-next-line react-hooks/exhaustive-deps + yChannel = 'brightness'; + break; + } + } else if (!yChannel) { + switch (xChannel) { + case 'hue': + yChannel = 'brightness'; + break; + case 'brightness': + yChannel = 'saturation'; + break; + default: + xChannel = 'saturation'; + yChannel = 'brightness'; + break; + } + } + } else if (colorSpace === 'hsl') { + colorSpaceSet = HSLSet; + if (!xChannel) { + switch (yChannel) { + case 'hue': + xChannel = 'lightness'; + break; + case 'lightness': + xChannel = 'saturation'; + default: + xChannel = 'saturation'; + yChannel = 'lightness'; + break; + } + } else if (!yChannel) { + switch (xChannel) { + case 'hue': + yChannel = 'lightness'; + break; + case 'lightness': + yChannel = 'saturation'; + default: + xChannel = 'saturation'; + yChannel = 'lightness'; + break; + } } - } else if (!yChannel) { - switch (xChannel) { - case 'red': - yChannel = 'green'; - break; - case 'blue': - yChannel = 'red'; - break; - default: - xChannel = 'blue'; - yChannel = 'green'; + } else if (colorSpace === 'rgb') { + colorSpaceSet = RGBSet; + if (!xChannel) { + switch (yChannel) { + case 'red': + case 'green': + xChannel = 'blue'; + break; + case 'blue': + xChannel = 'red'; + break; + default: + xChannel = 'blue'; + yChannel = 'green'; + } + } else if (!yChannel) { + switch (xChannel) { + case 'red': + yChannel = 'green'; + break; + case 'blue': + yChannel = 'red'; + break; + default: + xChannel = 'blue'; + yChannel = 'green'; + } } } + let xyChannels: Set = new Set([xChannel, yChannel]); - let zChannel = difference(RGBSet, xyChannels).values().next().value; + let zChannel = difference(colorSpaceSet, xyChannels).values().next().value as ColorChannel; return {xChannel, yChannel, zChannel}; }, [xChannel, yChannel]); diff --git a/packages/@react-stately/color/src/useColorWheelState.ts b/packages/@react-stately/color/src/useColorWheelState.ts index a43167f58c8..4fb53a51d96 100644 --- a/packages/@react-stately/color/src/useColorWheelState.ts +++ b/packages/@react-stately/color/src/useColorWheelState.ts @@ -167,7 +167,7 @@ export function useColorWheelState(props: ColorWheelProps): ColorWheelState { }, isDragging, getDisplayColor() { - return value.withChannelValue('saturation', 100).withChannelValue('lightness', 50); + return value.toFormat('hsl').withChannelValue('saturation', 100).withChannelValue('lightness', 50); } }; } diff --git a/packages/@react-types/color/src/index.d.ts b/packages/@react-types/color/src/index.d.ts index d84e0c1380a..51635629f51 100644 --- a/packages/@react-types/color/src/index.d.ts +++ b/packages/@react-types/color/src/index.d.ts @@ -51,6 +51,8 @@ export interface Color { toFormat(format: ColorFormat): Color, /** Converts the color to a string in the given format. */ toString(format: ColorFormat | 'css'): string, + /** Returns a duplicate of the color value. */ + clone(): Color, /** Converts the color to hex, and returns an integer representation. */ toHexInt(): number, /** @@ -75,7 +77,11 @@ export interface Color { /** * Formats the numeric value for a given channel for display according to the provided locale. */ - formatChannelValue(channel: ColorChannel, locale: string): string + formatChannelValue(channel: ColorChannel, locale: string): string, + /** + * Returns the color space, 'rgb', 'hsb' or 'hsl', for the current color. + */ + getColorSpace(): ColorFormat } export interface ColorFieldProps extends Omit, 'onChange'>, InputBase, Validation, FocusableProps, TextInputBase, LabelableProps { From ba5698da9864e0890d8a33c0905e3b7bf8b5aaf8 Mon Sep 17 00:00:00 2001 From: Michael Jordan Date: Mon, 6 Dec 2021 16:00:53 -0500 Subject: [PATCH 2/5] ColorArea: fix keyboard behavior for rtl locales and snapToStep for max value ColorArea: refine pageup/pagedown behaviors Omit change events when value doesn't change. useColorAreaState: fix onChangeEnd only after actual change --- packages/@react-stately/color/src/useColorAreaState.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/@react-stately/color/src/useColorAreaState.ts b/packages/@react-stately/color/src/useColorAreaState.ts index 383412283cd..43c6caaf65f 100644 --- a/packages/@react-stately/color/src/useColorAreaState.ts +++ b/packages/@react-stately/color/src/useColorAreaState.ts @@ -237,8 +237,6 @@ export function useColorAreaState(props: ColorAreaProps): ColorAreaState { yValue, setYValue, setColorFromPoint(x: number, y: number) { - let {minValue: minValueX, maxValue: maxValueX} = color.getChannelRange(channels.xChannel); - let {minValue: minValueY, maxValue: maxValueY} = color.getChannelRange(channels.yChannel); let newXValue = minValueX + clamp(x, 0, 1) * (maxValueX - minValueX); let newYValue = minValueY + (1 - clamp(y, 0, 1)) * (maxValueY - minValueY); let newColor:Color; @@ -262,10 +260,10 @@ export function useColorAreaState(props: ColorAreaProps): ColorAreaState { return {x, y}; }, incrementX(stepSize) { - setXValue(snapValueToStep(xValue + stepSize, minValueX, maxValueX, stepSize)); + setXValue(xValue + stepSize > maxValueX ? maxValueX : snapValueToStep(xValue + stepSize, minValueX, maxValueX, stepSize)); }, incrementY(stepSize) { - setYValue(snapValueToStep(yValue + stepSize, minValueY, maxValueY, stepSize)); + setYValue(yValue + stepSize > maxValueY ? maxValueY : snapValueToStep(yValue + stepSize, minValueY, maxValueY, stepSize)); }, decrementX(stepSize) { setXValue(snapValueToStep(xValue - stepSize, minValueX, maxValueX, stepSize)); From 3ecc4580cdea4e04b83d2bd5e4571198c79d7d23 Mon Sep 17 00:00:00 2001 From: Michael Jordan Date: Wed, 23 Feb 2022 20:15:12 -0500 Subject: [PATCH 3/5] fix(#2664): useColorSlider: refine PageUp/PageDown behaviors - Omit change events when value doesn't change. - Refine snapValueToStep for incrementing or decrementing from max. --- .../@react-aria/color/src/useColorSlider.ts | 42 +++++++++++++- .../color/stories/ColorArea.stories.tsx | 29 +++++----- .../color/test/ColorSlider.test.tsx | 55 ++++++++++++++++++- 3 files changed, 108 insertions(+), 18 deletions(-) diff --git a/packages/@react-aria/color/src/useColorSlider.ts b/packages/@react-aria/color/src/useColorSlider.ts index 4c15b946223..d3df75fd2a8 100644 --- a/packages/@react-aria/color/src/useColorSlider.ts +++ b/packages/@react-aria/color/src/useColorSlider.ts @@ -13,7 +13,8 @@ import {AriaColorSliderProps} from '@react-types/color'; import {ColorSliderState} from '@react-stately/color'; import {HTMLAttributes, RefObject} from 'react'; -import {mergeProps} from '@react-aria/utils'; +import {mergeProps, snapValueToStep} from '@react-aria/utils'; +import {useKeyboard} from '@react-aria/interactions'; import {useLocale} from '@react-aria/i18n'; import {useSlider, useSliderThumb} from '@react-aria/slider'; @@ -61,6 +62,43 @@ export function useColorSlider(props: ColorSliderAriaOptions, state: ColorSlider inputRef }, state); + let {minValue, maxValue, step, pageSize} = state.value.getChannelRange(channel); + let {keyboardProps} = useKeyboard({ + onKeyDown(e) { + // these are the cases that useMove or useSlider don't handle + if (!/^(PageUp|PageDown|Home|End)$/.test(e.key)) { + e.continuePropagation(); + return; + } + // same handling as useMove, stopPropagation to prevent useSlider from handling the event as well. + e.preventDefault(); + let channelValue = state.value.getChannelValue(channel); + let pageStep = Math.max(pageSize, step); + let newValue = channelValue; + switch (e.key) { + case 'PageUp': + newValue = channelValue + pageStep > maxValue ? maxValue : snapValueToStep(channelValue + pageStep, minValue, maxValue, pageStep); + break; + case 'PageDown': + newValue = snapValueToStep(channelValue - pageStep, minValue, maxValue, pageStep); + break; + case 'Home': + newValue = minValue; + break; + case 'End': + newValue = maxValue; + break; + } + // remember to set this so that onChangeEnd is fired + state.setThumbDragging(0, true); + if (newValue !== channelValue) { + state.setValue(state.value.withChannelValue(channel, newValue)); + } + // wait a frame to ensure value has changed then unset this so that onChangeEnd is fired + requestAnimationFrame(() => state.setThumbDragging(0, false)); + } + }); + let generateBackground = () => { let value = state.getDisplayColor(); let to: string; @@ -113,7 +151,7 @@ export function useColorSlider(props: ColorSliderAriaOptions, state: ColorSlider background: generateBackground() } }, - inputProps, + inputProps: mergeProps(inputProps, keyboardProps), thumbProps: { ...thumbProps, style: { diff --git a/packages/@react-spectrum/color/stories/ColorArea.stories.tsx b/packages/@react-spectrum/color/stories/ColorArea.stories.tsx index 3417c83e402..4a8d7efdff7 100644 --- a/packages/@react-spectrum/color/stories/ColorArea.stories.tsx +++ b/packages/@react-spectrum/color/stories/ColorArea.stories.tsx @@ -65,7 +65,8 @@ function ColorAreaExample(props: SpectrumColorAreaProps) { props.onChange(e); } setColor(e); - }} /> + }} + onChangeEnd={props.onChangeEnd} /> {isHue ? ( )} - +
{color.toString('hex')} @@ -155,11 +156,11 @@ XBlueYGreenSize600.args = {...XBlueYGreen.args, size: 'size-600'}; export let XSaturationYLightness = Template.bind({}); XSaturationYLightness.storyName = 'HSL xChannel="saturation", yChannel="lightness"'; -XSaturationYLightness.args = {xChannel: 'saturation', yChannel: 'lightness', defaultValue: 'hsl(0, 100%, 50%)', onChange: action('onChange')}; +XSaturationYLightness.args = {xChannel: 'saturation', yChannel: 'lightness', defaultValue: 'hsl(0, 100%, 50%)', onChange: action('onChange'), onChangeEnd: action('onChangeEnd')}; export let XLightnessYSaturation = Template.bind({}); XLightnessYSaturation.storyName = 'HSL xChannel="lightness", yChannel="saturation"'; -XLightnessYSaturation.args = {xChannel: 'lightness', yChannel: 'saturation', defaultValue: 'hsl(0, 100%, 50%)', onChange: action('onChange')}; +XLightnessYSaturation.args = {xChannel: 'lightness', yChannel: 'saturation', defaultValue: 'hsl(0, 100%, 50%)', onChange: action('onChange'), onChangeEnd: action('onChangeEnd')}; /* TODO: what does a disabled color area look like? */ export let XSaturationYLightnessisDisabled = Template.bind({}); @@ -168,11 +169,11 @@ XSaturationYLightnessisDisabled.args = {...XSaturationYLightness.args, isDisable export let XHueYSaturationHSL = Template.bind({}); XHueYSaturationHSL.storyName = 'HSL xChannel="hue", yChannel="saturation"'; -XHueYSaturationHSL.args = {xChannel: 'hue', yChannel: 'saturation', defaultValue: 'hsl(0, 100%, 50%)', onChange: action('onChange')}; +XHueYSaturationHSL.args = {xChannel: 'hue', yChannel: 'saturation', defaultValue: 'hsl(0, 100%, 50%)', onChange: action('onChange'), onChangeEnd: action('onChangeEnd')}; export let XSaturationYHueHSL = Template.bind({}); XSaturationYHueHSL.storyName = 'HSL xChannel="saturation", yChannel="hue"'; -XSaturationYHueHSL.args = {xChannel: 'saturation', yChannel: 'hue', defaultValue: 'hsl(0, 100%, 50%)', onChange: action('onChange')}; +XSaturationYHueHSL.args = {xChannel: 'saturation', yChannel: 'hue', defaultValue: 'hsl(0, 100%, 50%)', onChange: action('onChange'), onChangeEnd: action('onChangeEnd')}; /* TODO: what does a disabled color area look like? */ export let XHueYSaturationHSLisDisabled = Template.bind({}); @@ -181,11 +182,11 @@ XHueYSaturationHSLisDisabled.args = {...XHueYSaturationHSL.args, isDisabled: tru export let XHueYLightnessHSL = Template.bind({}); XHueYLightnessHSL.storyName = 'HSL xChannel="hue", yChannel="lightness"'; -XHueYLightnessHSL.args = {xChannel: 'hue', yChannel: 'lightness', defaultValue: 'hsl(0, 100%, 50%)', onChange: action('onChange')}; +XHueYLightnessHSL.args = {xChannel: 'hue', yChannel: 'lightness', defaultValue: 'hsl(0, 100%, 50%)', onChange: action('onChange'), onChangeEnd: action('onChangeEnd')}; export let XLightnessYHueHSL = Template.bind({}); XLightnessYHueHSL.storyName = 'HSL xChannel="lightness", yChannel="hue"'; -XLightnessYHueHSL.args = {xChannel: 'lightness', yChannel: 'hue', defaultValue: 'hsl(0, 100%, 50%)', onChange: action('onChange')}; +XLightnessYHueHSL.args = {xChannel: 'lightness', yChannel: 'hue', defaultValue: 'hsl(0, 100%, 50%)', onChange: action('onChange'), onChangeEnd: action('onChangeEnd')}; /* TODO: what does a disabled color area look like? */ export let XHueYLightnessHSLisDisabled = Template.bind({}); @@ -194,11 +195,11 @@ XHueYLightnessHSLisDisabled.args = {...XHueYLightnessHSL.args, isDisabled: true} export let XSaturationYBrightness = Template.bind({}); XSaturationYBrightness.storyName = 'HSB xChannel="saturation", yChannel="brightness"'; -XSaturationYBrightness.args = {xChannel: 'saturation', yChannel: 'brightness', defaultValue: 'hsb(0, 100%, 100%)', onChange: action('onChange')}; +XSaturationYBrightness.args = {xChannel: 'saturation', yChannel: 'brightness', defaultValue: 'hsb(0, 100%, 100%)', onChange: action('onChange'), onChangeEnd: action('onChangeEnd')}; export let XBrightnessYSaturation = Template.bind({}); XBrightnessYSaturation.storyName = 'HSB xChannel="brightness", yChannel="saturation"'; -XBrightnessYSaturation.args = {xChannel: 'brightness', yChannel: 'saturation', defaultValue: 'hsb(0, 100%, 100%)', onChange: action('onChange')}; +XBrightnessYSaturation.args = {xChannel: 'brightness', yChannel: 'saturation', defaultValue: 'hsb(0, 100%, 100%)', onChange: action('onChange'), onChangeEnd: action('onChangeEnd')}; /* TODO: what does a disabled color area look like? */ export let XSaturationYBrightnessisDisabled = Template.bind({}); @@ -207,11 +208,11 @@ XSaturationYBrightnessisDisabled.args = {...XSaturationYBrightness.args, isDisab export let XHueYSaturationHSB = Template.bind({}); XHueYSaturationHSB.storyName = 'HSB xChannel="hue", yChannel="saturation"'; -XHueYSaturationHSB.args = {xChannel: 'hue', yChannel: 'saturation', defaultValue: 'hsb(0, 100%, 100%)', onChange: action('onChange')}; +XHueYSaturationHSB.args = {xChannel: 'hue', yChannel: 'saturation', defaultValue: 'hsb(0, 100%, 100%)', onChange: action('onChange'), onChangeEnd: action('onChangeEnd')}; export let XSaturationYHueHSB = Template.bind({}); XSaturationYHueHSB.storyName = 'HSB xChannel="saturation", yChannel="hue"'; -XSaturationYHueHSB.args = {xChannel: 'saturation', yChannel: 'hue', defaultValue: 'hsb(0, 100%, 100%)', onChange: action('onChange')}; +XSaturationYHueHSB.args = {xChannel: 'saturation', yChannel: 'hue', defaultValue: 'hsb(0, 100%, 100%)', onChange: action('onChange'), onChangeEnd: action('onChangeEnd')}; /* TODO: what does a disabled color area look like? */ export let XHueYSaturationHSBisDisabled = Template.bind({}); @@ -220,11 +221,11 @@ XHueYSaturationHSBisDisabled.args = {...XHueYSaturationHSB.args, isDisabled: tru export let XHueYBrightnessHSB = Template.bind({}); XHueYBrightnessHSB.storyName = 'HSB xChannel="hue", yChannel="brightness"'; -XHueYBrightnessHSB.args = {xChannel: 'hue', yChannel: 'brightness', defaultValue: 'hsb(0, 100%, 100%)', onChange: action('onChange')}; +XHueYBrightnessHSB.args = {xChannel: 'hue', yChannel: 'brightness', defaultValue: 'hsb(0, 100%, 100%)', onChange: action('onChange'), onChangeEnd: action('onChangeEnd')}; export let XBrightnessYHueHSB = Template.bind({}); XBrightnessYHueHSB.storyName = 'HSB xChannel="brightness", yChannel="hue"'; -XBrightnessYHueHSB.args = {xChannel: 'brightness', yChannel: 'hue', defaultValue: 'hsb(0, 100%, 100%)', onChange: action('onChange')}; +XBrightnessYHueHSB.args = {xChannel: 'brightness', yChannel: 'hue', defaultValue: 'hsb(0, 100%, 100%)', onChange: action('onChange'), onChangeEnd: action('onChangeEnd')}; /* TODO: what does a disabled color area look like? */ export let XBrightnessYHueHSBisDisabled = Template.bind({}); diff --git a/packages/@react-spectrum/color/test/ColorSlider.test.tsx b/packages/@react-spectrum/color/test/ColorSlider.test.tsx index 3504ec28333..02f5e9d6bdb 100644 --- a/packages/@react-spectrum/color/test/ColorSlider.test.tsx +++ b/packages/@react-spectrum/color/test/ColorSlider.test.tsx @@ -19,18 +19,20 @@ import userEvent from '@testing-library/user-event'; describe('ColorSlider', () => { let onChangeSpy = jest.fn(); + let onChangeEndSpy = jest.fn(); beforeAll(() => { jest.spyOn(window.HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(() => 100); jest.spyOn(window.HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(() => 100); // @ts-ignore - jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => cb()); + jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => setTimeout(cb, 0)); // @ts-ignore jest.useFakeTimers('legacy'); }); afterEach(() => { onChangeSpy.mockClear(); + onChangeEndSpy.mockClear(); // for restoreTextSelection act(() => {jest.runAllTimers();}); }); @@ -264,16 +266,65 @@ describe('ColorSlider', () => { describe('keyboard events', () => { it('works', () => { let defaultColor = parseColor('#000000'); - let {getByRole} = render(); + let {getByRole} = render(); let slider = getByRole('slider'); act(() => {slider.focus();}); fireEvent.keyDown(slider, {key: 'Right'}); + act(() => {jest.runAllTimers();}); expect(onChangeSpy).toHaveBeenCalledTimes(1); expect(onChangeSpy.mock.calls[0][0].toString('hexa')).toBe(defaultColor.withChannelValue('red', 1).toString('hexa')); + expect(onChangeEndSpy).toHaveBeenCalledTimes(1); + expect(onChangeEndSpy.mock.calls[0][0].toString('hexa')).toBe(defaultColor.withChannelValue('red', 1).toString('hexa')); + fireEvent.keyDown(slider, {key: 'Left'}); + act(() => {jest.runAllTimers();}); expect(onChangeSpy).toHaveBeenCalledTimes(2); expect(onChangeSpy.mock.calls[1][0].toString('hexa')).toBe(defaultColor.withChannelValue('red', 0).toString('hexa')); + expect(onChangeEndSpy).toHaveBeenCalledTimes(2); + expect(onChangeEndSpy.mock.calls[1][0].toString('hexa')).toBe(defaultColor.withChannelValue('red', 0).toString('hexa')); + + fireEvent.keyDown(slider, {key: 'PageUp'}); + act(() => {jest.runAllTimers();}); + expect(onChangeSpy).toHaveBeenCalledTimes(3); + expect(onChangeSpy.mock.calls[2][0].toString('hexa')).toBe(defaultColor.withChannelValue('red', 16).toString('hexa')); + expect(onChangeEndSpy).toHaveBeenCalledTimes(3); + expect(onChangeEndSpy.mock.calls[2][0].toString('hexa')).toBe(defaultColor.withChannelValue('red', 16).toString('hexa')); + + fireEvent.keyDown(slider, {key: 'Right'}); + act(() => {jest.runAllTimers();}); + expect(onChangeSpy).toHaveBeenCalledTimes(4); + expect(onChangeSpy.mock.calls[3][0].toString('hexa')).toBe(defaultColor.withChannelValue('red', 17).toString('hexa')); + expect(onChangeEndSpy).toHaveBeenCalledTimes(4); + expect(onChangeEndSpy.mock.calls[3][0].toString('hexa')).toBe(defaultColor.withChannelValue('red', 17).toString('hexa')); + + fireEvent.keyDown(slider, {key: 'PageDown'}); + act(() => {jest.runAllTimers();}); + expect(onChangeSpy).toHaveBeenCalledTimes(5); + expect(onChangeSpy.mock.calls[4][0].toString('hexa')).toBe(defaultColor.withChannelValue('red', 0).toString('hexa')); + expect(onChangeEndSpy).toHaveBeenCalledTimes(5); + expect(onChangeEndSpy.mock.calls[4][0].toString('hexa')).toBe(defaultColor.withChannelValue('red', 0).toString('hexa')); + + fireEvent.keyDown(slider, {key: 'End'}); + act(() => {jest.runAllTimers();}); + expect(onChangeSpy).toHaveBeenCalledTimes(6); + expect(onChangeSpy.mock.calls[5][0].toString('hexa')).toBe(defaultColor.withChannelValue('red', 255).toString('hexa')); + expect(onChangeEndSpy).toHaveBeenCalledTimes(6); + expect(onChangeEndSpy.mock.calls[5][0].toString('hexa')).toBe(defaultColor.withChannelValue('red', 255).toString('hexa')); + + fireEvent.keyDown(slider, {key: 'PageDown'}); + act(() => {jest.runAllTimers();}); + expect(onChangeSpy).toHaveBeenCalledTimes(7); + expect(onChangeSpy.mock.calls[6][0].toString('hexa')).toBe(defaultColor.withChannelValue('red', 240).toString('hexa')); + expect(onChangeEndSpy).toHaveBeenCalledTimes(7); + expect(onChangeEndSpy.mock.calls[6][0].toString('hexa')).toBe(defaultColor.withChannelValue('red', 240).toString('hexa')); + + fireEvent.keyDown(slider, {key: 'Home'}); + act(() => {jest.runAllTimers();}); + expect(onChangeSpy).toHaveBeenCalledTimes(8); + expect(onChangeSpy.mock.calls[7][0].toString('hexa')).toBe(defaultColor.withChannelValue('red', 0).toString('hexa')); + expect(onChangeEndSpy).toHaveBeenCalledTimes(8); + expect(onChangeEndSpy.mock.calls[7][0].toString('hexa')).toBe(defaultColor.withChannelValue('red', 0).toString('hexa')); }); it('doesn\'t work when disabled', () => { From a5466b9a048febae7945d13c32d9eb03bfbf2444 Mon Sep 17 00:00:00 2001 From: Michael Jordan Date: Thu, 9 Dec 2021 14:27:20 -0500 Subject: [PATCH 4/5] ColorArea: update storybook example to use ColorField Demonstrates controlled state and color conversion from HSL/HSB/RGB in ColorArea/ColorWheel/ColorSlider and ColorField, which is currently RGB only. --- .../color/stories/ColorArea.stories.tsx | 58 ++++++++++--------- .../color/test/ColorArea.test.tsx | 6 +- 2 files changed, 37 insertions(+), 27 deletions(-) diff --git a/packages/@react-spectrum/color/stories/ColorArea.stories.tsx b/packages/@react-spectrum/color/stories/ColorArea.stories.tsx index 4a8d7efdff7..a5999e6ef40 100644 --- a/packages/@react-spectrum/color/stories/ColorArea.stories.tsx +++ b/packages/@react-spectrum/color/stories/ColorArea.stories.tsx @@ -11,14 +11,12 @@ */ import {action} from '@storybook/addon-actions'; -import {ColorArea, ColorSlider, ColorWheel} from '../'; +import {ColorArea, ColorField, ColorSlider, ColorWheel} from '../'; import {ColorChannel, SpectrumColorAreaProps} from '@react-types/color'; import {Flex} from '@adobe/react-spectrum'; import {Meta, Story} from '@storybook/react'; -import {parseColor} from '@react-stately/color'; +import {normalizeColor, parseColor} from '@react-stately/color'; import React, {useState} from 'react'; -import {Text} from '@react-spectrum/text'; - const meta: Meta = { title: 'ColorArea', @@ -53,29 +51,33 @@ function ColorAreaExample(props: SpectrumColorAreaProps) { } let zChannel: ColorChannel = difference(colorSpaceSet, channels).keys().next().value; let isHue = zChannel === 'hue'; + function onChange(e) { + try { + e = normalizeColor(e); + // eslint-disable-next-line no-empty + } catch (error) { + e = undefined; + return; + } + const newColor = (e || color).toFormat(colorSpace); + if (props.onChange) { + props.onChange(newColor); + } + setColor(newColor); + } return (
- + { - if (props.onChange) { - props.onChange(e); - } - setColor(e); - }} + onChange={onChange} onChangeEnd={props.onChangeEnd} /> {isHue ? ( { - if (props.onChange) { - props.onChange(e); - } - setColor(e); - }} + onChange={onChange} onChangeEnd={props.onChangeEnd} isDisabled={isDisabled} size={'size-2400'} @@ -85,20 +87,24 @@ function ColorAreaExample(props: SpectrumColorAreaProps) { ) : ( { - if (props.onChange) { - props.onChange(e); - } - setColor(e); - }} + onChange={onChange} onChangeEnd={props.onChangeEnd} channel={zChannel} isDisabled={isDisabled} /> )} - -
- {color.toString('hex')} + +
+ + event.key === 'Enter' && + onChange((event.target as HTMLInputElement).value) + } + isDisabled={isDisabled} + width="size-1200" />
); diff --git a/packages/@react-spectrum/color/test/ColorArea.test.tsx b/packages/@react-spectrum/color/test/ColorArea.test.tsx index a9ef7a834b9..558d9c593c0 100644 --- a/packages/@react-spectrum/color/test/ColorArea.test.tsx +++ b/packages/@react-spectrum/color/test/ColorArea.test.tsx @@ -540,12 +540,13 @@ describe('ColorArea', () => { }); it('the slider is focusable', () => { - let {getAllByRole} = render(
+ let {getAllByRole, getByRole} = render(
); let sliders = getAllByRole('slider'); + let colorField = getByRole('textbox'); let [buttonA, buttonB] = getAllByRole('button'); userEvent.tab(); @@ -555,8 +556,11 @@ describe('ColorArea', () => { userEvent.tab(); expect(document.activeElement).toBe(sliders[2]); userEvent.tab(); + expect(document.activeElement).toBe(colorField); + userEvent.tab(); expect(document.activeElement).toBe(buttonB); userEvent.tab({shift: true}); + userEvent.tab({shift: true}); expect(document.activeElement).toBe(sliders[2]); }); }); From 76922ca3aba738561d789f783319d4be62d79e7b Mon Sep 17 00:00:00 2001 From: Michael Jordan Date: Fri, 18 Feb 2022 12:38:55 -0500 Subject: [PATCH 5/5] ColorArea: add getChannels method to Color, returning Set of ColorChannels --- .../@react-aria/color/src/useColorArea.ts | 31 ++---- .../color/stories/ColorArea.stories.tsx | 18 +--- packages/@react-stately/color/src/Color.ts | 16 +++ .../color/src/useColorAreaState.ts | 97 ++++++------------- packages/@react-types/color/src/index.d.ts | 6 +- 5 files changed, 65 insertions(+), 103 deletions(-) diff --git a/packages/@react-aria/color/src/useColorArea.ts b/packages/@react-aria/color/src/useColorArea.ts index 0080f7723bc..261ffea9240 100644 --- a/packages/@react-aria/color/src/useColorArea.ts +++ b/packages/@react-aria/color/src/useColorArea.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {AriaColorAreaProps} from '@react-types/color'; +import {AriaColorAreaProps, ColorChannel} from '@react-types/color'; import {ColorAreaState} from '@react-stately/color'; import {focusWithoutScrolling, isAndroid, isIOS, mergeProps, useGlobalListeners, useLabels} from '@react-aria/utils'; // @ts-ignore @@ -299,27 +299,14 @@ export function useColorArea(props: AriaColorAreaProps, state: ColorAreaState, i let colorAriaLabellingProps = useLabels(props); let getValueTitle = () => { - switch (state.value.getColorSpace()) { - case 'hsb': - return [ - formatMessage('colorNameAndValue', {name: state.value.getChannelName('hue', locale), value: state.value.formatChannelValue('hue', locale)}), - formatMessage('colorNameAndValue', {name: state.value.getChannelName('saturation', locale), value: state.value.formatChannelValue('saturation', locale)}), - formatMessage('colorNameAndValue', {name: state.value.getChannelName('brightness', locale), value: state.value.formatChannelValue('brightness', locale)}) - ].join(', '); - case 'hsl': - return [ - formatMessage('colorNameAndValue', {name: state.value.getChannelName('hue', locale), value: state.value.formatChannelValue('hue', locale)}), - formatMessage('colorNameAndValue', {name: state.value.getChannelName('saturation', locale), value: state.value.formatChannelValue('saturation', locale)}), - formatMessage('colorNameAndValue', {name: state.value.getChannelName('lightness', locale), value: state.value.formatChannelValue('lightness', locale)}) - ].join(', '); - case 'rgb': - return [ - formatMessage('colorNameAndValue', {name: state.value.getChannelName('red', locale), value: state.value.formatChannelValue('red', locale)}), - formatMessage('colorNameAndValue', {name: state.value.getChannelName('green', locale), value: state.value.formatChannelValue('green', locale)}), - formatMessage('colorNameAndValue', {name: state.value.getChannelName('blue', locale), value: state.value.formatChannelValue('blue', locale)}) - ].join(', '); - } - return null; + const channels: Set = state.value.getColorChannels(); + const colorNamesAndValues = []; + channels.forEach(channel => + colorNamesAndValues.push( + formatMessage('colorNameAndValue', {name: state.value.getChannelName(channel, locale), value: state.value.formatChannelValue(channel, locale)}) + ) + ); + return colorNamesAndValues.length ? colorNamesAndValues.join(', ') : null; }; let ariaRoleDescription = isMobile ? null : formatMessage('twoDimensionalSlider'); diff --git a/packages/@react-spectrum/color/stories/ColorArea.stories.tsx b/packages/@react-spectrum/color/stories/ColorArea.stories.tsx index a5999e6ef40..29006ef8b4b 100644 --- a/packages/@react-spectrum/color/stories/ColorArea.stories.tsx +++ b/packages/@react-spectrum/color/stories/ColorArea.stories.tsx @@ -29,27 +29,15 @@ const Template: Story = (args) => ( ); -let RGB: Set = new Set(['red', 'green', 'blue']); -let HSL: Set = new Set(['hue', 'saturation', 'lightness']); -let HSB: Set = new Set(['hue', 'saturation', 'brightness']); let difference = (a, b): Set => new Set([...a].filter(x => !b.has(x))); function ColorAreaExample(props: SpectrumColorAreaProps) { let {xChannel, yChannel, isDisabled} = props; let defaultValue = typeof props.defaultValue === 'string' ? parseColor(props.defaultValue) : props.defaultValue; let [color, setColor] = useState(defaultValue || parseColor('#ff00ff')); - let channels = new Set([xChannel, yChannel]); - let colorSpace = color.getColorSpace(); - let colorSpaceSet = RGB; - switch (colorSpace) { - case 'hsl': - colorSpaceSet = HSL; - break; - case 'hsb': - colorSpaceSet = HSB; - break; - } - let zChannel: ColorChannel = difference(colorSpaceSet, channels).keys().next().value; + let xyChannels = new Set([xChannel, yChannel]); + let colorSpace = color.getColorSpace(); + let zChannel: ColorChannel = difference(color.getColorChannels(), xyChannels).keys().next().value; let isHue = zChannel === 'hue'; function onChange(e) { try { diff --git a/packages/@react-stately/color/src/Color.ts b/packages/@react-stately/color/src/Color.ts index cf01ce86fff..b8a81ca2ca3 100644 --- a/packages/@react-stately/color/src/Color.ts +++ b/packages/@react-stately/color/src/Color.ts @@ -71,6 +71,7 @@ abstract class Color implements IColor { } abstract getColorSpace(): ColorFormat + abstract getColorChannels(): Set } const HEX_REGEX = /^#(?:([0-9a-f]{3})|([0-9a-f]{6}))$/i; @@ -266,6 +267,11 @@ class RGBColor extends Color { getColorSpace(): ColorFormat { return 'rgb'; } + + private static colorChannels: Set = new Set(['red', 'green', 'blue']); + getColorChannels(): Set { + return RGBColor.colorChannels; + } } // X = @@ -399,6 +405,11 @@ class HSBColor extends Color { getColorSpace(): ColorFormat { return 'hsb'; } + + private static colorChannels: Set = new Set(['hue', 'saturation', 'brightness']); + getColorChannels(): Set { + return HSBColor.colorChannels; + } } // X = @@ -534,4 +545,9 @@ class HSLColor extends Color { getColorSpace(): ColorFormat { return 'hsl'; } + + private static colorChannels: Set = new Set(['hue', 'saturation', 'lightness']); + getColorChannels(): Set { + return HSLColor.colorChannels; + } } diff --git a/packages/@react-stately/color/src/useColorAreaState.ts b/packages/@react-stately/color/src/useColorAreaState.ts index 43c6caaf65f..93f46a5e9af 100644 --- a/packages/@react-stately/color/src/useColorAreaState.ts +++ b/packages/@react-stately/color/src/useColorAreaState.ts @@ -64,9 +64,6 @@ export interface ColorAreaState { } const DEFAULT_COLOR = parseColor('#ffffff'); -const RGBSet: Set = new Set(['red', 'green', 'blue']); -const HSLSet: Set = new Set(['hue', 'saturation', 'lightness']); -const HSBSet: Set = new Set(['hue', 'saturation', 'brightness']); let difference = (a: Set, b: Set): Set => new Set([...a].filter(x => !b.has(x))); /** @@ -88,72 +85,13 @@ export function useColorAreaState(props: ColorAreaProps): ColorAreaState { let channels = useMemo(() => { // determine the color space from the color value let colorSpace = valueRef.current.getColorSpace(); - let colorSpaceSet = RGBSet; - if (colorSpace === 'hsb') { - colorSpaceSet = HSBSet; - if (!xChannel) { - switch (yChannel) { - case 'hue': - // eslint-disable-next-line react-hooks/exhaustive-deps - xChannel = 'brightness'; - break; - case 'brightness': - xChannel = 'saturation'; - break; - default: - xChannel = 'saturation'; - // eslint-disable-next-line react-hooks/exhaustive-deps - yChannel = 'brightness'; - break; - } - } else if (!yChannel) { - switch (xChannel) { - case 'hue': - yChannel = 'brightness'; - break; - case 'brightness': - yChannel = 'saturation'; - break; - default: - xChannel = 'saturation'; - yChannel = 'brightness'; - break; - } - } - } else if (colorSpace === 'hsl') { - colorSpaceSet = HSLSet; - if (!xChannel) { - switch (yChannel) { - case 'hue': - xChannel = 'lightness'; - break; - case 'lightness': - xChannel = 'saturation'; - default: - xChannel = 'saturation'; - yChannel = 'lightness'; - break; - } - } else if (!yChannel) { - switch (xChannel) { - case 'hue': - yChannel = 'lightness'; - break; - case 'lightness': - yChannel = 'saturation'; - default: - xChannel = 'saturation'; - yChannel = 'lightness'; - break; - } - } - } else if (colorSpace === 'rgb') { - colorSpaceSet = RGBSet; + if (colorSpace === 'rgb') { if (!xChannel) { switch (yChannel) { case 'red': case 'green': + // eslint-disable-next-line react-hooks/exhaustive-deps xChannel = 'blue'; break; case 'blue': @@ -161,6 +99,7 @@ export function useColorAreaState(props: ColorAreaProps): ColorAreaState { break; default: xChannel = 'blue'; + // eslint-disable-next-line react-hooks/exhaustive-deps yChannel = 'green'; } } else if (!yChannel) { @@ -176,10 +115,38 @@ export function useColorAreaState(props: ColorAreaProps): ColorAreaState { yChannel = 'green'; } } + } else if (!xChannel) { + switch (yChannel) { + case 'hue': + xChannel = colorSpace === 'hsb' ? 'brightness' : 'lightness'; + break; + case 'brightness': + case 'lightness': + xChannel = 'saturation'; + break; + default: + xChannel = 'saturation'; + yChannel = colorSpace === 'hsb' ? 'brightness' : 'lightness'; + break; + } + } else if (!yChannel) { + switch (xChannel) { + case 'hue': + yChannel = colorSpace === 'hsb' ? 'brightness' : 'lightness'; + break; + case 'brightness': + case 'lightness': + yChannel = 'saturation'; + break; + default: + xChannel = 'saturation'; + yChannel = colorSpace === 'hsb' ? 'brightness' : 'lightness'; + break; + } } let xyChannels: Set = new Set([xChannel, yChannel]); - let zChannel = difference(colorSpaceSet, xyChannels).values().next().value as ColorChannel; + let zChannel = difference(valueRef.current.getColorChannels(), xyChannels).values().next().value as ColorChannel; return {xChannel, yChannel, zChannel}; }, [xChannel, yChannel]); diff --git a/packages/@react-types/color/src/index.d.ts b/packages/@react-types/color/src/index.d.ts index 51635629f51..10738977a64 100644 --- a/packages/@react-types/color/src/index.d.ts +++ b/packages/@react-types/color/src/index.d.ts @@ -81,7 +81,11 @@ export interface Color { /** * Returns the color space, 'rgb', 'hsb' or 'hsl', for the current color. */ - getColorSpace(): ColorFormat + getColorSpace(): ColorFormat, + /** + * Returns an array of the color channels within the current color space space. + */ + getColorChannels(): Set } export interface ColorFieldProps extends Omit, 'onChange'>, InputBase, Validation, FocusableProps, TextInputBase, LabelableProps {