Skip to content

Commit

Permalink
fix(circuit-ui): ColorInput paste and change events
Browse files Browse the repository at this point in the history
Improve ColorInput, specifically:

- handle default value and value props properly
- ensure color picker and hex color input are always synchronized
- handle properly paste events with hex color
  • Loading branch information
matoous committed Sep 17, 2024
1 parent 9d3ce2e commit 16ceaee
Show file tree
Hide file tree
Showing 3 changed files with 167 additions and 12 deletions.
117 changes: 115 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,11 @@
* limitations under the License.
*/

import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { createRef } from 'react';
import { userEvent } from '@storybook/test';

import { render, axe, screen } from '../../util/test-utils.js';
import { render, axe, screen, fireEvent } from '../../util/test-utils.js';
import type { InputElement } from '../Input/index.js';

import { ColorInput } from './ColorInput.js';
Expand Down Expand Up @@ -61,4 +62,116 @@ 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('oviously invalid');

const colorPicker = container.querySelector(
"input[type='color']",
) as HTMLInputElement;
expect(colorPicker.value).toBe('#000000');
expect(colorInput.value).toBe('');
});
});
});
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
59 changes: 50 additions & 9 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,35 @@ export const ColorInput = forwardRef<InputElement, ColorInputProps>(

const hasSuffix = Boolean(suffix);

const handlePaste: ClipboardEventHandler<InputElement> = (e) => {
if (
!colorPickerRef.current ||
!colorInputRef.current ||
disabled ||
readOnly
) {
return;
}

e.preventDefault();

const pastedText = e.clipboardData.getData('text/plain').trim();

if (!pastedText || !/^#[0-9A-F]{6}$/i.test(pastedText)) {
return;
}

colorPickerRef.current.value = pastedText;
colorInputRef.current.value = pastedText.replace('#', '');
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 +143,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 @@ -134,18 +168,22 @@ export const ColorInput = forwardRef<InputElement, ColorInputProps>(
<label htmlFor={pickerId} className={classes.picker}>
<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 +196,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 16ceaee

Please sign in to comment.