Skip to content

ColorArea: add HSB and HSL support #2570

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions packages/@react-aria/color/src/useColorArea.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this change going to cause issues if I'm using an app and I change my locale? I'm also curious if there could be issues with this component being used in a collaborative app where one person has their locale in one language/region and the other has it set to a different one. This code should be transforming the data for display to the user, but double checking.

const channels: Set<ColorChannel> = 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');

Expand Down
42 changes: 40 additions & 2 deletions packages/@react-aria/color/src/useColorSlider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why would pressing PageUp ever cause the user to see the maxValue? Isn't PageUp moving upward, i.e. closer to the minValue? When I tested this pageup/pagedown seemed backwards.

Also, why does snapValueToStep have this broder issue when moving up, but not with PageDown? This feels like a bug with snapValueToStep. I noticed in useColorAreaState.ts there was this min/max check on both values not just one like here.

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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't the setThumDragging only change if the value changes?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to set it and then immediately unset it just so that onChangeEnd is fired

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;
Expand Down Expand Up @@ -113,7 +151,7 @@ export function useColorSlider(props: ColorSliderAriaOptions, state: ColorSlider
background: generateBackground()
}
},
inputProps,
inputProps: mergeProps(inputProps, keyboardProps),
thumbProps: {
...thumbProps,
style: {
Expand Down
82 changes: 82 additions & 0 deletions packages/@react-spectrum/color/src/ColorArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}
}
}

Expand Down
169 changes: 137 additions & 32 deletions packages/@react-spectrum/color/stories/ColorArea.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<SpectrumColorAreaProps> = {
title: 'ColorArea',
Expand All @@ -31,41 +29,70 @@ const Template: Story<SpectrumColorAreaProps> = (args) => (
<ColorAreaExample {...args} />
);

let RGB: Set<ColorChannel> = new Set(['red', 'green', 'blue']);
let difference = (a, b): Set<ColorChannel> => 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 (<div role="group" aria-label="RGB Color Picker">
<Flex gap="size-500" alignItems="center">
<Flex direction="column" gap="size-50" alignItems="center">
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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is normalizeColor throwing an error you need to catch?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding ColorField to the story means that parseColor might throw an error if the string entered into the ColorField cannot be parsed to a color.

export function normalizeColor(v: string | IColor) {
  if (typeof v === 'string') {
    return parseColor(v);
  } else {
    return v;
  }
}

// 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 (<div role="group" aria-label={`${colorSpace.toUpperCase()} Color Picker`}>
<Flex gap="size-500" alignItems="start">
<Flex direction="column" gap={isHue ? 0 : 'size-50'} alignItems="center">
<ColorArea
size={isHue ? 'size-1200' : null}
{...props}
value={color}
onChange={(e) => {
if (props.onChange) {
props.onChange(e);
}
setColor(e);
}} />
<ColorSlider
value={color}
onChange={(e) => {
if (props.onChange) {
props.onChange(e);
}
setColor(e);
}}
onChangeEnd={props.onChangeEnd}
channel={zChannel}
isDisabled={isDisabled} />
onChange={onChange}
onChangeEnd={props.onChangeEnd} />
{isHue ? (
<ColorWheel
value={color}
onChange={onChange}
onChangeEnd={props.onChangeEnd}
isDisabled={isDisabled}
size={'size-2400'}
UNSAFE_style={{
marginTop: 'calc( -.75 * var(--spectrum-global-dimension-size-2400))'
}} />
) : (
<ColorSlider
value={color}
onChange={onChange}
onChangeEnd={props.onChangeEnd}
channel={zChannel}
isDisabled={isDisabled} />
)}
</Flex>
<Flex direction="column" alignItems="center" gap="size-100" minWidth={'size-2000'}>
<div role="img" aria-label={`color swatch: ${color.toString('rgb')}`} title={`${color.toString('hex')}`} style={{width: '100px', height: '100px', background: color.toString('css')}} />
<Text>{color.toString('hex')}</Text>
<Flex direction="column" alignItems="center" gap="size-100" minWidth="size-1200">
<div role="img" aria-label={`color swatch: ${color.toString('rgb')}`} title={`${color.toString('hex')}`} style={{width: '96px', height: '96px', background: color.toString('css')}} />
<ColorField
label="HEX Color"
value={color}
onChange={onChange}
onKeyDown={event =>
event.key === 'Enter' &&
onChange((event.target as HTMLInputElement).value)
}
isDisabled={isDisabled}
width="size-1200" />
</Flex>
</Flex>
</div>);
Expand All @@ -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({});
Expand Down Expand Up @@ -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({});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This a lot of new stories to test. I know we are just following the pattern of the RGB stories above. Wondering if it would make more sense to add these to chromatic storybook and only having one or two examples of this or a dropdown to change a single story between all of these, or checkboxes to change toggle the examples between all these combinations?

XSaturationYLightness.storyName = 'HSL xChannel="saturation", yChannel="lightness"';
XSaturationYLightness.args = {xChannel: 'saturation', yChannel: 'lightness', defaultValue: 'hsl(0, 100%, 50%)', onChange: action('onChange'), onChangeEnd: action('onChangeEnd')};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we care about the onChange and onChangeEnd events for these stories? I'm speaking to these not being on every RGB story.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

they are on every rgb story in the latest on the color-area branch, they're inherited from the base one


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? */
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this todo resolved or need to another PR or an issue?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we've got this pretty much resolved, I'll remove the comment, thanks

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};
Comment on lines +172 to +175
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question, maybe something to bring up to spectrum? I think making everything gray (like what you have) is probably easiest since we won't have to worry about if the disabled handle is too hard too see and it maybe confusing if we only change the handle style but leave the color area filled in


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};
Loading