diff --git a/.changeset/thick-deers-act.md b/.changeset/thick-deers-act.md new file mode 100644 index 00000000000..82fd6820fc9 --- /dev/null +++ b/.changeset/thick-deers-act.md @@ -0,0 +1,9 @@ +--- +'@itwin/itwinui-react': minor +--- + +Added the ability to have custom `props` for each sub-component of `ColorPicker`. +New props added associated with each sub-component are: +- `ColorBuilder`: `colorFieldProps`, `colorDotProps`, `opacitySliderProps`, and `hueSliderProps`. +- `ColorInputPanel`: `panelLabelProps`, `colorInputContainerProps`, `inputFieldsGroupProps` and `swapColorFormatButtonProps`. +- `ColorPalette`: `labelProps`, and `paletteContainerProps`. diff --git a/packages/itwinui-react/src/core/ColorPicker/ColorBuilder.tsx b/packages/itwinui-react/src/core/ColorPicker/ColorBuilder.tsx index 1caec77b9cf..f918060ce65 100644 --- a/packages/itwinui-react/src/core/ColorPicker/ColorBuilder.tsx +++ b/packages/itwinui-react/src/core/ColorPicker/ColorBuilder.tsx @@ -10,6 +10,7 @@ import { useEventListener, useMergedRefs, Box, + mergeEventHandlers, } from '../../utils/index.js'; import type { HsvColor, @@ -27,13 +28,39 @@ const getHorizontalPercentageOfRectangle = (rect: DOMRect, pointer: number) => { return ((position - rect.left) / rect.width) * 100; }; +type ColorBuilderProps = { + /** + * Passes props to the color field. + */ + colorFieldProps?: React.ComponentProps<'div'>; + /** + * Passes props to the color dot. + */ + colorDotProps?: React.ComponentProps<'div'>; + /** + * Passes props to the color opacity slider. + */ + opacitySliderProps?: React.ComponentProps; + /** + * Passes props to the color hue slider. + */ + hueSliderProps?: React.ComponentProps; +}; + /** * `ColorBuilder` consists of two parts: * a color canvas to adjust saturation and lightness values, * and a set of sliders to adjust hue and alpha values. */ export const ColorBuilder = React.forwardRef((props, ref) => { - const { className, ...rest } = props; + const { + className, + colorFieldProps, + colorDotProps, + opacitySliderProps, + hueSliderProps, + ...rest + } = props; const builderRef = React.useRef(); const refs = useMergedRefs(builderRef, ref); @@ -253,37 +280,55 @@ export const ColorBuilder = React.forwardRef((props, ref) => { {...rest} > { - event.preventDefault(); - updateSquareValue(event, 'onClick'); - setColorDotActive(true); - colorDotRef.current?.focus(); - }} + ref={useMergedRefs(squareRef, colorFieldProps?.ref)} + onPointerDown={mergeEventHandlers( + colorFieldProps?.onPointerDown, + (event: React.PointerEvent) => { + event.preventDefault(); + updateSquareValue(event, 'onClick'); + setColorDotActive(true); + colorDotRef.current?.focus(); + }, + )} > { - setColorDotActive(true); - colorDotRef.current?.focus(); - }} - onKeyDown={handleColorDotKeyDown} - onKeyUp={handleColorDotKeyUp} + onPointerDown={mergeEventHandlers( + colorDotProps?.onPointerDown, + () => { + setColorDotActive(true); + colorDotRef.current?.focus(); + }, + )} + onKeyDown={mergeEventHandlers( + colorDotProps?.onKeyDown, + handleColorDotKeyDown, + )} + onKeyUp={mergeEventHandlers( + colorDotProps?.onKeyUp, + handleColorDotKeyUp, + )} tabIndex={0} - ref={colorDotRef} + ref={useMergedRefs(colorDotRef, colorDotProps?.ref)} /> @@ -291,18 +336,27 @@ export const ColorBuilder = React.forwardRef((props, ref) => { minLabel='' maxLabel='' values={[sliderValue]} - className='iui-hue-slider' trackDisplayMode='none' - tooltipProps={() => ({ visible: false })} + min={0} + max={359} + {...hueSliderProps} + className={cx('iui-hue-slider', hueSliderProps?.className)} + tooltipProps={() => ({ + visible: false, + ...hueSliderProps?.tooltipProps, + })} onChange={(values) => { + hueSliderProps?.onChange?.(values); updateHueSlider(values[0], true); }} onUpdate={(values) => { + hueSliderProps?.onUpdate?.(values); updateHueSlider(values[0], false); }} - min={0} - max={359} - thumbProps={() => ({ 'aria-label': 'Hue' })} + thumbProps={() => ({ + 'aria-label': 'Hue', + ...hueSliderProps?.thumbProps, + })} /> {showAlpha && ( @@ -310,29 +364,39 @@ export const ColorBuilder = React.forwardRef((props, ref) => { minLabel='' maxLabel='' values={[alphaValue]} - className='iui-opacity-slider' trackDisplayMode='none' - tooltipProps={() => ({ visible: false })} + min={0} + max={1} + step={0.01} + {...opacitySliderProps} + className={cx('iui-opacity-slider', opacitySliderProps?.className)} + tooltipProps={() => ({ + visible: false, + ...opacitySliderProps?.tooltipProps, + })} onChange={(values) => { + opacitySliderProps?.onChange?.(values); updateOpacitySlider(values[0], true); }} onUpdate={(values) => { + opacitySliderProps?.onUpdate?.(values); updateOpacitySlider(values[0], false); }} - min={0} - max={1} - step={0.01} style={ { '--iui-color-picker-selected-color': hueColorString, + ...opacitySliderProps?.style, } as React.CSSProperties } - thumbProps={() => ({ 'aria-label': 'Opacity' })} + thumbProps={() => ({ + 'aria-label': 'Opacity', + ...opacitySliderProps?.thumbProps, + })} /> )} ); -}) as PolymorphicForwardRefComponent<'div'>; +}) as PolymorphicForwardRefComponent<'div', ColorBuilderProps>; if (process.env.NODE_ENV === 'development') { ColorBuilder.displayName = 'ColorBuilder'; } diff --git a/packages/itwinui-react/src/core/ColorPicker/ColorInputPanel.test.tsx b/packages/itwinui-react/src/core/ColorPicker/ColorInputPanel.test.tsx index d81ced58672..4c515c83211 100644 --- a/packages/itwinui-react/src/core/ColorPicker/ColorInputPanel.test.tsx +++ b/packages/itwinui-react/src/core/ColorPicker/ColorInputPanel.test.tsx @@ -8,6 +8,92 @@ import { ColorInputPanel } from './ColorInputPanel.js'; import { ColorValue } from '../../utils/index.js'; import { userEvent } from '@testing-library/user-event'; +it('should pass custom label with props', async () => { + const { container } = render( + + + , + ); + + const inputPanelLabel = container.querySelector( + '.iui-color-picker-section-label.test-panel-label', + ) as HTMLElement; + expect(inputPanelLabel).toBeTruthy(); + expect(inputPanelLabel.style.color).toBe('red'); +}); + +it('should render input field with custom container props', async () => { + const { container } = render( + + + , + ); + + const inputPanel = container.querySelector( + '.iui-color-input.test-input-panel', + ) as HTMLElement; + expect(inputPanel).toBeTruthy(); + expect(inputPanel.style.padding).toBe('10px'); +}); + +it('should render swap color format button with custom props', async () => { + const { container } = render( + + + , + ); + + const swapColorButton = container.querySelector( + '.iui-button[data-iui-variant="high-visibility"]', + ) as HTMLElement; + expect(swapColorButton).toBeTruthy(); + expect(swapColorButton).toHaveClass('test-swap-color-button'); +}); + +it('should render input field with custom input field props', async () => { + const logSpy = vitest.spyOn(console, 'log'); + const { container } = render( + + console.log('clicked'), + }} + /> + , + ); + + const inputPanel = container.querySelector( + '.iui-color-input-fields.test-input-field', + ) as HTMLElement; + expect(inputPanel).toBeTruthy(); + expect(inputPanel.style.borderRadius).toBe('10px'); + await userEvent.click(inputPanel); + expect(logSpy).toHaveBeenCalledWith('clicked'); +}); + it('should render ColorInputPanel with input fields', async () => { const { container } = render( diff --git a/packages/itwinui-react/src/core/ColorPicker/ColorInputPanel.tsx b/packages/itwinui-react/src/core/ColorPicker/ColorInputPanel.tsx index e5f8c42e3ad..e8110e14890 100644 --- a/packages/itwinui-react/src/core/ColorPicker/ColorInputPanel.tsx +++ b/packages/itwinui-react/src/core/ColorPicker/ColorInputPanel.tsx @@ -6,7 +6,14 @@ import * as React from 'react'; import cx from 'classnames'; import { IconButton } from '../Buttons/IconButton.js'; import { Input } from '../Input/Input.js'; -import { ColorValue, SvgSwap, Box, useId } from '../../utils/index.js'; +import { + ColorValue, + SvgSwap, + Box, + useId, + useMergedRefs, + mergeEventHandlers, +} from '../../utils/index.js'; import type { PolymorphicForwardRefComponent } from '../../utils/index.js'; import { useColorPickerContext } from './ColorPickerContext.js'; @@ -23,6 +30,22 @@ type ColorInputPanelProps = { * @default ['hsl', 'rgb', 'hex'] */ allowedColorFormats?: ('hsl' | 'rgb' | 'hex')[]; + /** + * Passes props to the color picker section label. + */ + panelLabelProps?: React.ComponentProps<'div'>; + /** + * Passes props to the color input container. + */ + colorInputContainerProps?: React.ComponentProps<'div'>; + /** + * Passes props to the color input fields group. + */ + inputFieldsGroupProps?: React.ComponentProps<'div'>; + /** + * Passes props to the swap color format button. + */ + swapColorFormatButtonProps?: React.ComponentProps; }; /** @@ -39,6 +62,10 @@ export const ColorInputPanel = React.forwardRef((props, ref) => { defaultColorFormat, allowedColorFormats = ['hsl', 'rgb', 'hex'], className, + colorInputContainerProps, + panelLabelProps, + inputFieldsGroupProps, + swapColorFormatButtonProps, ...rest } = props; @@ -450,35 +477,58 @@ export const ColorInputPanel = React.forwardRef((props, ref) => { ); - const labelId = useId(); + const fallbackLabelId = useId(); + const labelId = panelLabelProps?.id ?? fallbackLabelId; return ( - + {showAlpha && currentFormat !== 'hex' ? currentFormat.toUpperCase() + 'A' : currentFormat.toUpperCase()} - + {allowedColorFormats.length > 1 && ( )} {currentFormat === 'hex' && hexInputField} {currentFormat === 'rgb' && rgbInputs} diff --git a/packages/itwinui-react/src/core/ColorPicker/ColorPalette.test.tsx b/packages/itwinui-react/src/core/ColorPicker/ColorPalette.test.tsx index b9f4beb85d0..d919d66052c 100644 --- a/packages/itwinui-react/src/core/ColorPicker/ColorPalette.test.tsx +++ b/packages/itwinui-react/src/core/ColorPicker/ColorPalette.test.tsx @@ -38,6 +38,65 @@ it('should render in its most basic state', () => { }); }); +it('should pass custom label with props', () => { + const colors = [ + 'hsla(210, 11%, 65%, 1.00)', + 'hsla(95, 73%, 16%, 1.00)', + 'hsla(203, 100%, 6%, 1.00)', + 'hsla(203, 100%, 13%, 1.00)', + ]; + + const { container } = render( + + + , + ); + + const paletteLabel = container.querySelector( + '.iui-color-picker-section-label.test-picker-label', + ) as HTMLElement; + + expect(paletteLabel).toBeTruthy(); + expect(paletteLabel.style.color).toBe('red'); + expect(paletteLabel.style.fontSize).toBe('16px'); +}); + +it('should pass color palette with props', () => { + const colors = [ + 'hsla(210, 11%, 65%, 1.00)', + 'hsla(95, 73%, 16%, 1.00)', + 'hsla(203, 100%, 6%, 1.00)', + 'hsla(203, 100%, 13%, 1.00)', + ]; + + const { container } = render( + + + , + ); + + const palette = container.querySelector( + '.iui-color-palette.test-palette', + ) as HTMLElement; + + expect(palette).toBeTruthy(); + expect(palette.style.gap).toBe('10px'); +}); + it('should render with selectedColor', () => { const colors = [ 'hsla(210, 11%, 65%, 1.00)', diff --git a/packages/itwinui-react/src/core/ColorPicker/ColorPalette.tsx b/packages/itwinui-react/src/core/ColorPicker/ColorPalette.tsx index 6012db21da7..06027d32d2e 100644 --- a/packages/itwinui-react/src/core/ColorPicker/ColorPalette.tsx +++ b/packages/itwinui-react/src/core/ColorPicker/ColorPalette.tsx @@ -18,6 +18,10 @@ export type ColorPaletteProps = { * Label shown above the palette. */ label?: React.ReactNode; + /** + * Passes props to the color picker section label. + */ + labelProps?: React.ComponentProps<'div'>; /** * List of colors shown as swatches in the palette. */ @@ -26,6 +30,10 @@ export type ColorPaletteProps = { * Pass any custom swatches as children. */ children?: React.ReactNode; + /** + * Passes props to the color palette container. + */ + paletteContainerProps?: React.ComponentProps<'div'>; }; /** @@ -40,7 +48,15 @@ export type ColorPaletteProps = { * */ export const ColorPalette = React.forwardRef((props, ref) => { - const { colors, label, className, children, ...rest } = props; + const { + colors, + label, + labelProps, + className, + children, + paletteContainerProps, + ...rest + } = props; const { activeColor, setActiveColor, onChangeComplete } = useColorPickerContext(); @@ -51,8 +67,23 @@ export const ColorPalette = React.forwardRef((props, ref) => { ref={ref} {...rest} > - {label && {label}} - + {label && ( + + {label} + + )} + {children} {colors && colors.map((_color, index) => { diff --git a/packages/itwinui-react/src/core/ColorPicker/ColorPicker.test.tsx b/packages/itwinui-react/src/core/ColorPicker/ColorPicker.test.tsx index f88d1b0bfee..b47ae5ef7b9 100644 --- a/packages/itwinui-react/src/core/ColorPicker/ColorPicker.test.tsx +++ b/packages/itwinui-react/src/core/ColorPicker/ColorPicker.test.tsx @@ -42,6 +42,97 @@ it('should add className and style correctly', () => { expect(swatch).toHaveStyle('width: 100px'); }); +it('should render color field with custom props', () => { + const { container } = render( + + + , + ); + + const colorField = container.querySelector( + '.iui-color-field.test-color-field', + ) as HTMLElement; + expect(colorField).toBeTruthy(); + expect(colorField.style.borderRadius).toBe('10px'); +}); + +it('should render color dot with custom props', () => { + const { container } = render( + + + , + ); + + const colorDot = container.querySelector( + '.iui-color-dot.test-color-dot', + ) as HTMLElement; + expect(colorDot).toBeTruthy(); +}); + +it('should render color builder with custom hue slider', () => { + const handleChange = vi.fn(); + const { container } = render( + + + , + ); + + const hueSlider = container.querySelector( + '.iui-hue-slider.test-hue-slider', + ) as HTMLElement; + expect(hueSlider).toBeTruthy(); + const hueSliderThumb = container.querySelector( + '.iui-slider-thumb', + ) as HTMLDivElement; + fireEvent.pointerDown(hueSliderThumb, { + pointerId: 5, + buttons: 1, + clientX: 210, + }); + expect(handleChange).toHaveBeenCalledTimes(1); +}); + +it('should render color builder with custom opacity slider', () => { + const handleChange = vi.fn(); + const { container } = render( + + + , + ); + + const opacitySlider = container.querySelector( + '.iui-opacity-slider.test-opacity-slider', + ) as HTMLElement; + expect(opacitySlider).toBeTruthy(); + const opacitySliderThumb = container.querySelectorAll( + '.iui-slider-thumb', + )[1] as HTMLElement; + fireEvent.keyDown(opacitySliderThumb, { key: 'ArrowLeft' }); + fireEvent.keyUp(opacitySliderThumb, { key: 'ArrowLeft' }); + expect(handleChange).toHaveBeenCalledTimes(1); +}); + it('should render advanced color picker with no color swatches', () => { const { container } = render(