Skip to content

Commit d9f7b1a

Browse files
feat(react-color-picker): Added custom channels for ColorSlider (#33763)
Co-authored-by: Dmytro Kirpa <kirpadv@gmail.com>
1 parent 5b50917 commit d9f7b1a

21 files changed

+357
-30
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "minor",
3+
"comment": "custom color channels",
4+
"packageName": "@fluentui/react-color-picker-preview",
5+
"email": "v.kozlova13@gmail.com",
6+
"dependentChangeType": "patch"
7+
}

packages/react-components/react-color-picker-preview/library/etc/react-color-picker-preview.api.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export const AlphaSlider: ForwardRefComponent<AlphaSliderProps>;
2020
export const alphaSliderClassNames: SlotClassNames<AlphaSliderSlots>;
2121

2222
// @public
23-
export type AlphaSliderProps = ColorSliderProps & {
23+
export type AlphaSliderProps = Omit<ColorSliderProps, 'channel'> & {
2424
transparency?: boolean;
2525
};
2626

@@ -83,7 +83,7 @@ export const colorSliderClassNames: SlotClassNames<ColorSliderSlots>;
8383

8484
// @public
8585
export type ColorSliderProps = Omit<ComponentProps<Partial<ColorSliderSlots>, 'input'>, 'defaultValue' | 'onChange' | 'value' | 'color'> & Pick<ColorPickerProps, 'shape'> & {
86-
channel?: string;
86+
channel?: ColorChannel;
8787
onChange?: EventHandler<SliderOnChangeData>;
8888
vertical?: boolean;
8989
color?: HsvColor;
@@ -99,7 +99,7 @@ export type ColorSliderSlots = {
9999
};
100100

101101
// @public
102-
export type ColorSliderState = ComponentState<ColorSliderSlots> & Pick<ColorSliderProps, 'vertical' | 'shape'>;
102+
export type ColorSliderState = ComponentState<ColorSliderSlots> & Pick<ColorSliderProps, 'vertical' | 'shape' | 'channel'>;
103103

104104
// @public
105105
export const renderAlphaSlider_unstable: (state: AlphaSliderState) => JSX.Element;

packages/react-components/react-color-picker-preview/library/src/components/AlphaSlider/AlphaSlider.types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export type AlphaSliderSlots = ColorSliderSlots;
66
/**
77
* AlphaSlider Props
88
*/
9-
export type AlphaSliderProps = ColorSliderProps & {
9+
export type AlphaSliderProps = Omit<ColorSliderProps, 'channel'> & {
1010
/**
1111
* The `transparency` property determines how the alpha channel is interpreted.
1212
* - When `false`, the alpha channel represents the opacity of the color.

packages/react-components/react-color-picker-preview/library/src/components/ColorSlider/ColorSlider.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ describe('ColorSlider', () => {
1717
<div
1818
class="fui-ColorSlider"
1919
role="group"
20-
style="--fui-Slider--direction: -90deg; --fui-Slider--progress: 0%; --fui-Slider__thumb--color: hsl(0, 100%, 50%);"
20+
style="--fui-Slider--direction: -90deg; --fui-Slider--progress: 0%; --fui-Slider__thumb--color: hsl(0, 100%, 50%); --fui-Slider__rail--color: hsl(0 0%, 0%);"
2121
>
2222
<input
2323
aria-orientation="horizontal"

packages/react-components/react-color-picker-preview/library/src/components/ColorSlider/ColorSlider.types.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ export type ColorSliderSlots = {
1414
input: NonNullable<Slot<'input'>>;
1515
};
1616

17+
export type ColorChannel = 'hue' | 'saturation' | 'value';
18+
1719
/**
1820
* ColorSlider Props
1921
*/
@@ -22,7 +24,11 @@ export type ColorSliderProps = Omit<
2224
'defaultValue' | 'onChange' | 'value' | 'color'
2325
> &
2426
Pick<ColorPickerProps, 'shape'> & {
25-
channel?: string;
27+
/**
28+
* Color channel of the Slider.
29+
* @default `hue`
30+
*/
31+
channel?: ColorChannel;
2632

2733
/**
2834
* Triggers a callback when the value has been changed. This will be called on every individual step.
@@ -49,4 +55,5 @@ export type ColorSliderProps = Omit<
4955
/**
5056
* State used in rendering ColorSlider
5157
*/
52-
export type ColorSliderState = ComponentState<ColorSliderSlots> & Pick<ColorSliderProps, 'vertical' | 'shape'>;
58+
export type ColorSliderState = ComponentState<ColorSliderSlots> &
59+
Pick<ColorSliderProps, 'vertical' | 'shape' | 'channel'>;

packages/react-components/react-color-picker-preview/library/src/components/ColorSlider/useColorSlider.ts

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,22 @@
11
import * as React from 'react';
2+
import { tinycolor } from '@ctrl/tinycolor';
23
import {
34
getPartitionedNativeProps,
45
useId,
56
slot,
6-
clamp,
77
useControllableState,
88
useEventCallback,
99
} from '@fluentui/react-utilities';
1010
import { colorSliderCSSVars } from './useColorSliderStyles.styles';
1111
import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts';
1212
import type { ColorSliderProps, ColorSliderState } from './ColorSlider.types';
1313
import { useColorPickerContextValue_unstable } from '../../contexts/colorPicker';
14-
import { MIN, HUE_MAX as MAX } from '../../utils/constants';
14+
import { MIN, HUE_MAX, MAX as COLOR_MAX } from '../../utils/constants';
1515
import { getPercent } from '../../utils/getPercent';
1616
import { createHsvColor } from '../../utils/createHsvColor';
17+
import { clampValue, type ChannelActions, adjustChannel } from '../../utils/adjustChannel';
18+
import { HsvColor } from '../../types/color';
19+
import { INITIAL_COLOR_HSV } from '../../utils/constants';
1720

1821
/**
1922
* Create the state required to render ColorSlider.
@@ -42,6 +45,7 @@ export const useColorSlider_unstable = (
4245

4346
const {
4447
color,
48+
channel = 'hue',
4549
onChange = onChangeFromContext,
4650
shape = shapeFromContext,
4751
vertical,
@@ -53,34 +57,61 @@ export const useColorSlider_unstable = (
5357
} = props;
5458

5559
const hsvColor = color || colorFromContext;
60+
const hslColor = tinycolor(hsvColor).toHsl();
5661

57-
const [currentValue, setCurrentValue] = useControllableState({
58-
defaultState: props.defaultColor?.h,
59-
state: hsvColor?.h,
60-
initialState: 0,
62+
const [currentColor, setCurrentColor] = useControllableState<HsvColor>({
63+
defaultState: props.defaultColor,
64+
state: hsvColor,
65+
initialState: INITIAL_COLOR_HSV,
6166
});
62-
const clampedValue = clamp(currentValue, MIN, MAX);
67+
68+
const MAX = channel === 'hue' ? HUE_MAX : COLOR_MAX;
69+
70+
const valueChannelActions: ChannelActions<number> = {
71+
hue: clampValue(currentColor.h),
72+
saturation: clampValue(currentColor.s * 100),
73+
value: clampValue(currentColor.v * 100),
74+
};
75+
76+
const clampedValue = adjustChannel(channel, valueChannelActions);
6377
const valuePercent = getPercent(clampedValue, MIN, MAX);
6478

6579
const inputOnChange = input?.onChange;
6680

6781
const _onChange: React.ChangeEventHandler<HTMLInputElement> = useEventCallback(event => {
6882
const newValue = Number(event.target.value);
69-
const newColor = createHsvColor({ ...hsvColor, h: newValue });
70-
setCurrentValue(newValue);
83+
const colorActions: ChannelActions<() => HsvColor> = {
84+
hue: () => createHsvColor({ ...hsvColor, h: newValue }),
85+
saturation: () => createHsvColor({ ...hsvColor, s: newValue / 100 }),
86+
value: () => createHsvColor({ ...hsvColor, v: newValue / 100 }),
87+
};
88+
const newColor = adjustChannel(channel, colorActions)();
89+
90+
setCurrentColor(newColor);
91+
7192
inputOnChange?.(event);
72-
onChange?.(event, { type: 'change', event, color: newColor });
93+
onChange?.(event, {
94+
type: 'change',
95+
event,
96+
color: newColor,
97+
});
7398
});
7499

75100
const rootVariables = {
76101
[colorSliderCSSVars.sliderDirectionVar]: vertical ? '180deg' : dir === 'ltr' ? '-90deg' : '90deg',
77102
[colorSliderCSSVars.sliderProgressVar]: `${valuePercent}%`,
78-
[colorSliderCSSVars.thumbColorVar]: `hsl(${clampedValue}, 100%, 50%)`,
103+
[colorSliderCSSVars.thumbColorVar]:
104+
channel === 'hue' ? `hsl(${clampedValue}, 100%, 50%)` : tinycolor(hsvColor).toRgbString(),
105+
[colorSliderCSSVars.railColorVar]:
106+
channel === 'hue'
107+
? `hsl(${hslColor.h} ${hslColor.s * 100}%, ${hslColor.l * 100}%)`
108+
: `hsl(${hslColor.h} 100%, 50%)`,
79109
};
80110

81111
const state: ColorSliderState = {
82112
shape,
83113
vertical,
114+
channel,
84115
components: {
85116
input: 'input',
86117
rail: 'div',

packages/react-components/react-color-picker-preview/library/src/components/ColorSlider/useColorSliderStyles.styles.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export const colorSliderCSSVars = {
1414
sliderDirectionVar: `--fui-Slider--direction`,
1515
sliderProgressVar: `--fui-Slider--progress`,
1616
thumbColorVar: `--fui-Slider__thumb--color`,
17+
railColorVar: `--fui-Slider__rail--color`,
1718
};
1819

1920
// Internal CSS variables
@@ -62,9 +63,18 @@ const useStyles = makeStyles({
6263
gridTemplateRows: `1fr 100% 1fr`,
6364
gridTemplateColumns: `1fr var(${thumbSizeVar}) 1fr`,
6465
},
66+
});
67+
68+
const useChannelStyles = makeStyles({
6569
hue: {
6670
backgroundImage: hueBackground,
6771
},
72+
saturation: {
73+
backgroundImage: `linear-gradient(to right, #808080, var(${colorSliderCSSVars.railColorVar}))`,
74+
},
75+
value: {
76+
backgroundImage: `linear-gradient(to right, #000, var(${colorSliderCSSVars.railColorVar}))`,
77+
},
6878
});
6979

7080
/**
@@ -200,6 +210,7 @@ export const useColorSliderStyles_unstable = (state: ColorSliderState): ColorSli
200210
const thumbStyles = useThumbStyles();
201211
const inputStyles = useInputStyles();
202212
const shapeStyles = useShapeStyles();
213+
const channelStyles = useChannelStyles();
203214
const isVertical = state.vertical;
204215

205216
state.root.className = mergeClasses(
@@ -212,7 +223,7 @@ export const useColorSliderStyles_unstable = (state: ColorSliderState): ColorSli
212223
state.rail.className = mergeClasses(
213224
colorSliderClassNames.rail,
214225
railStyles.rail,
215-
styles.hue,
226+
channelStyles[state.channel || 'hue'],
216227
shapeStyles[state.shape || 'rounded'],
217228
isVertical ? railStyles.vertical : railStyles.horizontal,
218229
state.rail.className,
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { clampValue, adjustChannel, ChannelActions } from './adjustChannel';
2+
import { MIN, HUE_MAX, MAX as COLOR_MAX } from './constants';
3+
4+
describe('clampValue', () => {
5+
it('should clamp value within the hue range', () => {
6+
expect(clampValue(-10, 'hue')).toBe(MIN);
7+
expect(clampValue(370, 'hue')).toBe(HUE_MAX);
8+
expect(clampValue(180, 'hue')).toBe(180);
9+
});
10+
11+
it('should clamp value within the saturation/value range', () => {
12+
expect(clampValue(-10, 'saturation')).toBe(MIN);
13+
expect(clampValue(110, 'saturation')).toBe(COLOR_MAX);
14+
expect(clampValue(50, 'saturation')).toBe(50);
15+
16+
expect(clampValue(-10, 'value')).toBe(MIN);
17+
expect(clampValue(110, 'value')).toBe(COLOR_MAX);
18+
expect(clampValue(50, 'value')).toBe(50);
19+
});
20+
21+
it('should default to hue if no channel is provided', () => {
22+
expect(clampValue(-10)).toBe(MIN);
23+
expect(clampValue(370)).toBe(HUE_MAX);
24+
expect(clampValue(180)).toBe(180);
25+
});
26+
});
27+
28+
describe('adjustChannel', () => {
29+
const actions: ChannelActions<string> = {
30+
hue: 'hueAction',
31+
saturation: 'saturationAction',
32+
value: 'valueAction',
33+
};
34+
35+
it('should return the correct action for the given channel', () => {
36+
expect(adjustChannel('hue', actions)).toBe('hueAction');
37+
expect(adjustChannel('saturation', actions)).toBe('saturationAction');
38+
expect(adjustChannel('value', actions)).toBe('valueAction');
39+
});
40+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { ColorChannel } from '../components/ColorSlider/ColorSlider.types';
2+
import { MIN, HUE_MAX, MAX as COLOR_MAX } from './constants';
3+
import { clamp } from '@fluentui/react-utilities';
4+
5+
/**
6+
* Clamps a given value to the valid range for a specified color channel.
7+
*
8+
* @param value - The numeric value to be clamped.
9+
* @param channel - The color channel to use for clamping. Defaults to 'hue'.
10+
* @returns The clamped value within the range defined by the color channel.
11+
*/
12+
export function clampValue(value: number, channel: ColorChannel = 'hue') {
13+
const MAX = channel === 'hue' ? HUE_MAX : COLOR_MAX;
14+
return clamp(value, MIN, MAX);
15+
}
16+
17+
export type ChannelActions<T> = {
18+
hue: T;
19+
saturation: T;
20+
value: T;
21+
};
22+
23+
/**
24+
* Adjusts the specified color channel using the provided actions.
25+
*
26+
* @template T - The type of the result returned by the actions.
27+
* @param {ColorChannel} channel - The color channel to adjust.
28+
* @param {ChannelActions<T>} actions - An object containing actions for each color channel.
29+
* @returns {T} - The result of the action corresponding to the specified channel, or the hue action if the channel is not found.
30+
*/
31+
export function adjustChannel<T>(channel: ColorChannel, actions: ChannelActions<T>): T {
32+
return actions[channel] || actions.hue;
33+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { createHsvColor } from './createHsvColor';
2+
import { HsvColor } from '../types/color';
3+
4+
describe('createHsvColor', () => {
5+
it('should create an HSV color with default values', () => {
6+
const color: HsvColor = createHsvColor({});
7+
expect(color).toEqual({ h: 0, s: 0, v: 0, a: 1 });
8+
});
9+
10+
it('should create an HSV color with specified hue', () => {
11+
const color: HsvColor = createHsvColor({ h: 120 });
12+
expect(color).toEqual({ h: 120, s: 0, v: 0, a: 1 });
13+
});
14+
15+
it('should create an HSV color with specified saturation', () => {
16+
const color: HsvColor = createHsvColor({ s: 50 });
17+
expect(color).toEqual({ h: 0, s: 50, v: 0, a: 1 });
18+
});
19+
20+
it('should create an HSV color with specified value', () => {
21+
const color: HsvColor = createHsvColor({ v: 75 });
22+
expect(color).toEqual({ h: 0, s: 0, v: 75, a: 1 });
23+
});
24+
25+
it('should create an HSV color with specified alpha', () => {
26+
const color: HsvColor = createHsvColor({ a: 0.5 });
27+
expect(color).toEqual({ h: 0, s: 0, v: 0, a: 0.5 });
28+
});
29+
30+
it('should create an HSV color with all specified components', () => {
31+
const color: HsvColor = createHsvColor({ h: 180, s: 100, v: 100, a: 0.8 });
32+
expect(color).toEqual({ h: 180, s: 100, v: 100, a: 0.8 });
33+
});
34+
});

0 commit comments

Comments
 (0)