diff --git a/.changeset/beige-planes-shave.md b/.changeset/beige-planes-shave.md new file mode 100644 index 0000000000..84e28fc0e4 --- /dev/null +++ b/.changeset/beige-planes-shave.md @@ -0,0 +1,5 @@ +--- +"@sumup/circuit-ui": patch +--- + +Fixed event and values handling for the experimental ColorInput component. diff --git a/package-lock.json b/package-lock.json index 7e18960f58..2c5fa57a87 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42143,7 +42143,7 @@ }, "packages/icons": { "name": "@sumup/icons", - "version": "4.1.0", + "version": "4.1.2", "license": "Apache-2.0", "devDependencies": { "@babel/core": "^7.25.2", diff --git a/packages/circuit-ui/components/ColorInput/ColorInput.module.css b/packages/circuit-ui/components/ColorInput/ColorInput.module.css index 4803a1b778..385826e27f 100644 --- a/packages/circuit-ui/components/ColorInput/ColorInput.module.css +++ b/packages/circuit-ui/components/ColorInput/ColorInput.module.css @@ -26,6 +26,12 @@ box-shadow: 0 0 0 2px var(--cui-border-focus); } +.picker[data-disabled="true"] { + color: var(--cui-fg-normal-disabled); + background-color: var(--cui-bg-normal-disabled); + box-shadow: 0 0 0 1px var(--cui-border-normal-disabled); +} + .color-input { width: var(--cui-spacings-giga); height: var(--cui-spacings-giga); diff --git a/packages/circuit-ui/components/ColorInput/ColorInput.spec.tsx b/packages/circuit-ui/components/ColorInput/ColorInput.spec.tsx index 2ba0e907fd..ea43d78306 100644 --- a/packages/circuit-ui/components/ColorInput/ColorInput.spec.tsx +++ b/packages/circuit-ui/components/ColorInput/ColorInput.spec.tsx @@ -13,10 +13,16 @@ * limitations under the License. */ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { createRef } from 'react'; -import { render, axe, screen } from '../../util/test-utils.js'; +import { + render, + axe, + screen, + fireEvent, + userEvent, +} from '../../util/test-utils.js'; import type { InputElement } from '../Input/index.js'; import { ColorInput } from './ColorInput.js'; @@ -61,4 +67,132 @@ describe('ColorInput', () => { ); }); }); + + it('should set value and default value on both inputs', () => { + const { container } = render( + , + ); + const colorPicker = container.querySelector( + "input[type='color']", + ) as HTMLInputElement; + const colorInput = container.querySelector( + "input[type='text']", + ) as HTMLInputElement; + expect(colorPicker.value).toBe('#ff11bb'); + expect(colorInput.value).toBe('ff11bb'); + }); + + describe('Synchronization', () => { + it('should update text input if color input changes', async () => { + const { container } = render(); + const colorPicker = container.querySelector( + "input[type='color']", + ) as HTMLInputElement; + const newValue = '#00ff00'; + + fireEvent.input(colorPicker, { target: { value: newValue } }); + + const colorInput = container.querySelector( + "input[type='text']", + ) as HTMLInputElement; + expect(colorInput.value).toBe(newValue.replace('#', '')); + }); + + it('should update color input if text input changes', async () => { + const { container } = render(); + const colorInput = container.querySelector( + "input[type='text']", + ) as HTMLInputElement; + const newValue = '00ff00'; + + await userEvent.type(colorInput, newValue); + + const colorPicker = container.querySelector( + "input[type='color']", + ) as HTMLInputElement; + expect(colorPicker.value).toBe(`#${newValue}`); + }); + }); + + describe('OnChange events', () => { + it('should trigger onChange event when color picker changes', async () => { + const onChange = vi.fn(); + const { container } = render( + , + ); + + const colorPicker = container.querySelector( + "input[type='color']", + ) as HTMLInputElement; + + fireEvent.input(colorPicker, { target: { value: '#00ff00' } }); + + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('should trigger onChange event when color hex input changes', async () => { + const onChange = vi.fn(); + const { container } = render( + , + ); + + const colorInput = container.querySelector( + "input[type='text']", + ) as HTMLInputElement; + + await userEvent.type(colorInput, '00ff00'); + + expect(onChange).toHaveBeenCalled(); + }); + }); + + describe('Paste', () => { + it('should handle paste events', async () => { + const { container } = render(); + const colorInput = container.querySelector( + "input[type='text']", + ) as HTMLInputElement; + + await userEvent.click(colorInput); + await userEvent.paste('#00ff00'); + + const colorPicker = container.querySelector( + "input[type='color']", + ) as HTMLInputElement; + expect(colorPicker.value).toBe('#00ff00'); + expect(colorInput.value).toBe('00ff00'); + }); + + it('should ignore invalid paste event', async () => { + const { container } = render(); + const colorInput = container.querySelector( + "input[type='text']", + ) as HTMLInputElement; + + await userEvent.click(colorInput); + await userEvent.paste('obviously invalid'); + + const colorPicker = container.querySelector( + "input[type='color']", + ) as HTMLInputElement; + expect(colorPicker.value).toBe('#000000'); + expect(colorInput.value).toBe(''); + }); + + it("should allow pasting color without '#'", async () => { + const { container } = render(); + const colorInput = container.querySelector( + "input[type='text']", + ) as HTMLInputElement; + + await userEvent.click(colorInput); + await userEvent.paste('00ff00'); + + const colorPicker = container.querySelector( + "input[type='color']", + ) as HTMLInputElement; + expect(colorPicker.value).toBe('#00ff00'); + expect(colorInput.value).toBe('00ff00'); + }); + }); }); diff --git a/packages/circuit-ui/components/ColorInput/ColorInput.stories.tsx b/packages/circuit-ui/components/ColorInput/ColorInput.stories.tsx index f9a264367a..00715861df 100644 --- a/packages/circuit-ui/components/ColorInput/ColorInput.stories.tsx +++ b/packages/circuit-ui/components/ColorInput/ColorInput.stories.tsx @@ -23,7 +23,8 @@ export default { const baseArgs = { label: 'Color', pickerLabel: 'Pick color', - placeholder: '99ffbb', + placeholder: '#99ffbb', + defaultValue: '#99ffbb', }; export const Base = (args: ColorInputProps) => ( diff --git a/packages/circuit-ui/components/ColorInput/ColorInput.tsx b/packages/circuit-ui/components/ColorInput/ColorInput.tsx index 0947bc68b4..15f1cfbc4f 100644 --- a/packages/circuit-ui/components/ColorInput/ColorInput.tsx +++ b/packages/circuit-ui/components/ColorInput/ColorInput.tsx @@ -19,8 +19,8 @@ import { forwardRef, useId, useRef, - useState, type ChangeEventHandler, + type ClipboardEventHandler, } from 'react'; import { classes as inputClasses } from '../Input/index.js'; @@ -80,6 +80,7 @@ export const ColorInput = forwardRef( onChange, optionalLabel, validationHint, + placeholder, readOnly, required, inputClassName, @@ -89,10 +90,8 @@ export const ColorInput = forwardRef( }, ref, ) => { - const [currentColor, setCurrentColor] = useState( - defaultValue, - ); const colorPickerRef = useRef(null); + const colorInputRef = useRef(null); const labelId = useId(); const pickerId = useId(); @@ -106,8 +105,45 @@ export const ColorInput = forwardRef( const hasSuffix = Boolean(suffix); + const handlePaste: ClipboardEventHandler = (e) => { + if (!colorPickerRef.current || !colorInputRef.current || readOnly) { + return; + } + + e.preventDefault(); + + const pastedText = e.clipboardData.getData('text/plain').trim(); + + if (!pastedText || !/^#?[0-9A-F]{6}$/i.test(pastedText)) { + return; + } + + const pastedColor = pastedText.startsWith('#') + ? pastedText + : `#${pastedText}`; + + colorPickerRef.current.value = pastedColor; + + // React overwrites the input.value setter. In order to be able to trigger + // a 'change' event on the input, we need to use the native setter. + // Adapted from https://stackoverflow.com/a/46012210/4620154 + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )?.set?.call(colorInputRef.current, pastedColor.replace('#', '')); + + colorInputRef.current.dispatchEvent( + new Event('change', { bubbles: true }), + ); + colorPickerRef.current.dispatchEvent( + new Event('change', { bubbles: true }), + ); + }; + const onPickerColorChange: ChangeEventHandler = (e) => { - setCurrentColor(e.target.value); + if (colorInputRef.current) { + colorInputRef.current.value = e.target.value.replace('#', ''); + } if (onChange) { onChange(e); } @@ -117,7 +153,15 @@ export const ColorInput = forwardRef( if (colorPickerRef.current) { colorPickerRef.current.value = `#${e.target.value}`; } - setCurrentColor(`#${e.target.value}`); + if (onChange) { + onChange({ + ...e, + target: { + ...e.target, + value: `#${e.target.value}`, + }, + }); + } }; return ( @@ -131,21 +175,29 @@ export const ColorInput = forwardRef( />
-