diff --git a/packages/@react-aria/color/src/useColorArea.ts b/packages/@react-aria/color/src/useColorArea.ts index db31620e441..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 @@ -298,11 +298,16 @@ 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 = () => { + 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-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/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..29006ef8b4b 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} 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', @@ -31,41 +29,70 @@ const Template: Story = (args) => ( ); -let RGB: Set = new Set(['red', 'green', 'blue']); let difference = (a, b): Set => new Set([...a].filter(x => !b.has(x))); function ColorAreaExample(props: SpectrumColorAreaProps) { let {xChannel, yChannel, isDisabled} = props; - 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 defaultValue = typeof props.defaultValue === 'string' ? parseColor(props.defaultValue) : props.defaultValue; + let [color, setColor] = useState(defaultValue || parseColor('#ff00ff')); + 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 { + 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); - }} /> - { - if (props.onChange) { - props.onChange(e); - } - setColor(e); - }} - onChangeEnd={props.onChangeEnd} - channel={zChannel} - isDisabled={isDisabled} /> + onChange={onChange} + onChangeEnd={props.onChangeEnd} /> + {isHue ? ( + + ) : ( + + )} - -
- {color.toString('hex')} + +
+ + event.key === 'Enter' && + onChange((event.target as HTMLInputElement).value) + } + isDisabled={isDisabled} + width="size-1200" />
); @@ -84,7 +111,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 +147,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'), 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'), onChangeEnd: action('onChangeEnd')}; + +/* 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'), 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'), onChangeEnd: action('onChangeEnd')}; + +/* 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'), 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'), onChangeEnd: action('onChangeEnd')}; + +/* 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'), 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'), onChangeEnd: action('onChangeEnd')}; + +/* 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'), 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'), onChangeEnd: action('onChangeEnd')}; + +/* 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'), 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'), onChangeEnd: action('onChangeEnd')}; + +/* 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-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]); }); }); 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', () => { 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 1a82f98ea73..93f46a5e9af 100644 --- a/packages/@react-stately/color/src/useColorAreaState.ts +++ b/packages/@react-stately/color/src/useColorAreaState.ts @@ -64,7 +64,6 @@ export interface ColorAreaState { } const DEFAULT_COLOR = parseColor('#ffffff'); -const RGBSet: Set = new Set(['red', 'green', 'blue']); let difference = (a: Set, b: Set): Set => new Set([...a].filter(x => !b.has(x))); /** @@ -84,34 +83,70 @@ export function useColorAreaState(props: ColorAreaProps): ColorAreaState { valueRef.current = color; let channels = useMemo(() => { - if (!xChannel) { + // determine the color space from the color value + let colorSpace = valueRef.current.getColorSpace(); + + if (colorSpace === 'rgb') { + if (!xChannel) { + switch (yChannel) { + case 'red': + case 'green': + // eslint-disable-next-line react-hooks/exhaustive-deps + xChannel = 'blue'; + break; + case 'blue': + xChannel = 'red'; + break; + default: + xChannel = 'blue'; + // eslint-disable-next-line react-hooks/exhaustive-deps + yChannel = 'green'; + } + } else if (!yChannel) { + switch (xChannel) { + case 'red': + yChannel = 'green'; + break; + case 'blue': + yChannel = 'red'; + break; + default: + xChannel = 'blue'; + yChannel = 'green'; + } + } + } else if (!xChannel) { switch (yChannel) { - case 'red': - case 'green': - xChannel = 'blue'; + case 'hue': + xChannel = colorSpace === 'hsb' ? 'brightness' : 'lightness'; break; - case 'blue': - xChannel = 'red'; + case 'brightness': + case 'lightness': + xChannel = 'saturation'; break; default: - xChannel = 'blue'; - yChannel = 'green'; + xChannel = 'saturation'; + yChannel = colorSpace === 'hsb' ? 'brightness' : 'lightness'; + break; } } else if (!yChannel) { switch (xChannel) { - case 'red': - yChannel = 'green'; + case 'hue': + yChannel = colorSpace === 'hsb' ? 'brightness' : 'lightness'; break; - case 'blue': - yChannel = 'red'; + case 'brightness': + case 'lightness': + yChannel = 'saturation'; break; default: - xChannel = 'blue'; - yChannel = 'green'; + xChannel = 'saturation'; + yChannel = colorSpace === 'hsb' ? 'brightness' : 'lightness'; + break; } } + let xyChannels: Set = new Set([xChannel, yChannel]); - let zChannel = difference(RGBSet, xyChannels).values().next().value; + let zChannel = difference(valueRef.current.getColorChannels(), xyChannels).values().next().value as ColorChannel; return {xChannel, yChannel, zChannel}; }, [xChannel, yChannel]); @@ -169,8 +204,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; @@ -194,10 +227,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)); 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..10738977a64 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,15 @@ 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, + /** + * 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 {