Skip to content

Commit

Permalink
fix(circuit-ui): ColorInput paste and change events (#2687)
Browse files Browse the repository at this point in the history
  • Loading branch information
matoous authored Oct 12, 2024
1 parent 14643b2 commit 2e85454
Show file tree
Hide file tree
Showing 6 changed files with 215 additions and 14 deletions.
5 changes: 5 additions & 0 deletions .changeset/beige-planes-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sumup/circuit-ui": patch
---

Fixed event and values handling for the experimental ColorInput component.
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
138 changes: 136 additions & 2 deletions packages/circuit-ui/components/ColorInput/ColorInput.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -61,4 +67,132 @@ describe('ColorInput', () => {
);
});
});

it('should set value and default value on both inputs', () => {
const { container } = render(
<ColorInput {...baseProps} defaultValue="#ff11bb" />,
);
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(<ColorInput {...baseProps} />);
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(<ColorInput {...baseProps} />);
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(
<ColorInput {...baseProps} onChange={onChange} />,
);

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(
<ColorInput {...baseProps} onChange={onChange} />,
);

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(<ColorInput {...baseProps} />);
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(<ColorInput {...baseProps} />);
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(<ColorInput {...baseProps} />);
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');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ export default {
const baseArgs = {
label: 'Color',
pickerLabel: 'Pick color',
placeholder: '99ffbb',
placeholder: '#99ffbb',
defaultValue: '#99ffbb',
};

export const Base = (args: ColorInputProps) => (
Expand Down
75 changes: 65 additions & 10 deletions packages/circuit-ui/components/ColorInput/ColorInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ import {
forwardRef,
useId,
useRef,
useState,
type ChangeEventHandler,
type ClipboardEventHandler,
} from 'react';

import { classes as inputClasses } from '../Input/index.js';
Expand Down Expand Up @@ -80,6 +80,7 @@ export const ColorInput = forwardRef<InputElement, ColorInputProps>(
onChange,
optionalLabel,
validationHint,
placeholder,
readOnly,
required,
inputClassName,
Expand All @@ -89,10 +90,8 @@ export const ColorInput = forwardRef<InputElement, ColorInputProps>(
},
ref,
) => {
const [currentColor, setCurrentColor] = useState<string | undefined>(
defaultValue,
);
const colorPickerRef = useRef<InputElement>(null);
const colorInputRef = useRef<InputElement>(null);

const labelId = useId();
const pickerId = useId();
Expand All @@ -106,8 +105,45 @@ export const ColorInput = forwardRef<InputElement, ColorInputProps>(

const hasSuffix = Boolean(suffix);

const handlePaste: ClipboardEventHandler<InputElement> = (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<InputElement> = (e) => {
setCurrentColor(e.target.value);
if (colorInputRef.current) {
colorInputRef.current.value = e.target.value.replace('#', '');
}
if (onChange) {
onChange(e);
}
Expand All @@ -117,7 +153,15 @@ export const ColorInput = forwardRef<InputElement, ColorInputProps>(
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 (
Expand All @@ -131,21 +175,29 @@ export const ColorInput = forwardRef<InputElement, ColorInputProps>(
/>
</FieldLegend>
<div className={classes.wrapper}>
<label htmlFor={pickerId} className={classes.picker}>
<label
htmlFor={pickerId}
className={classes.picker}
data-disabled={disabled}
>
<input
id={pickerId}
ref={applyMultipleRefs(colorPickerRef, ref)}
type="color"
aria-labelledby={labelId}
aria-describedby={descriptionIds}
className={classes['color-input']}
onChange={onPickerColorChange}
ref={applyMultipleRefs(colorPickerRef, ref)}
readOnly={readOnly}
disabled={disabled}
defaultValue={defaultValue}
value={value}
/>
</label>
<span className={classes.symbol}>#</span>
<input
id={id}
ref={colorInputRef}
type="text"
aria-labelledby={labelId}
aria-describedby={descriptionIds}
Expand All @@ -158,12 +210,15 @@ export const ColorInput = forwardRef<InputElement, ColorInputProps>(
)}
aria-invalid={invalid && 'true'}
required={required}
disabled={disabled}
maxLength={6}
pattern="[0-9a-f]{3,6}"
readOnly={readOnly}
value={currentColor ? currentColor.replace('#', '') : undefined}
disabled={disabled}
value={value?.replace('#', '')}
defaultValue={defaultValue?.replace('#', '')}
placeholder={placeholder?.replace('#', '')}
onChange={onInputChange}
onPaste={handlePaste}
{...props}
/>
</div>
Expand Down

0 comments on commit 2e85454

Please sign in to comment.