Skip to content

Commit a4b5d2c

Browse files
sirineJconnor-baer
andauthored
chore(circuit-ui): DSYS-823 | mark phone number & color inputs as stable (#2731)
* mark ColorInput and PhoneNumberInput as Stable. Improve readonly ColorInput styles * Revise PhoneNumberInput docs * add stories * remove irrelevant props * refactor and add tests * update docs --------- Co-authored-by: Connor Bär <[email protected]>
1 parent c15abee commit a4b5d2c

File tree

11 files changed

+212
-172
lines changed

11 files changed

+212
-172
lines changed

.changeset/hip-comics-agree.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@sumup-oss/circuit-ui": major
3+
---
4+
5+
Marked the `ColorInput` and `PhoneNumberInput` components as stable. Update the related imports:
6+
7+
```diff
8+
- import { ColorInput, type ColorInputProps } from '@sumup-oss/circuit-ui/experimental';
9+
+ import { ColorInput, type ColorInputProps } from '@sumup-oss/circuit-ui';
10+
```
11+
12+
```diff
13+
- import { PhoneNumberInput, type PhoneNumberInputProps } from '@sumup-oss/circuit-ui/experimental';
14+
+ import { PhoneNumberInput, type PhoneNumberInputProps } from '@sumup-oss/circuit-ui';
15+
```
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@sumup-oss/eslint-plugin-circuit-ui": minor
3+
---
4+
5+
Updated the `component-lifecycle-imports` ESLint rule to handle imports of `ColorInput` and `PhoneNumberInput` as experimental components.

packages/circuit-ui/components/ColorInput/ColorInput.mdx

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,46 @@ import * as Stories from './ColorInput.stories';
55

66
# ColorInput
77

8-
<Status variant="experimental" />
8+
## Use cases and API
9+
<Status variant="stable" />
910

10-
The ColorInput component enables users to type or select a color.
11+
The ColorInput component enables users to type a color in a text field or select it using the browser native color picker.
12+
13+
Use the ColorInput component to allow users to input a color value to personalize content such as UI themes, profile settings or other customizable experiences.
14+
15+
16+
The component only supports seven-character string representations of colors in hexadecimal format. Shorthand Hex values like `#fff` will not be normalized to `#ffffff` and values with alpha channel like `##ffffff50` will not be accepted.
17+
- Accepted: `#aabbcc`, `#AABBCC`
18+
- Not accepted: `#abc`, `#aabbccaa`, `#ABC`, `#AABBCCAA`
1119

1220
<Story of={Stories.Base} />
21+
1322
<Props />
23+
24+
## Validations
25+
26+
Use the `validationHint` prop to communication information about the state of the ColorInput component, along with one of the following validation props:
27+
- stand-alone: The user is informed of the expected response.
28+
- `showWarning`: The user is warned that the value is not recommended but still valid.
29+
- `showError`: The user is alerted that the value is invalid.
30+
- `showValid`: The user is reassured that the value is valid. Use sparingly.
31+
32+
<Story of={Stories.Validations} />
33+
34+
## Optional
35+
36+
Use the `optionalLabel` prop to indicate that the field is optional. This can help reduce the cognitive load for the user by clearly indicating which fields are required and which are not. This label is only displayed when the `required` prop is falsy.
37+
38+
<Story of={Stories.Optional} />
39+
40+
## Readonly
41+
42+
The ColorInput component supports a read-only state. Use the `readOnly` prop to indicate that the field is not currently editable.
43+
The read-only state is applied to the text input field of the ColorInput component, while disabling the color picker input to prevent interactions with the element.
44+
45+
<Story of={Stories.Readonly} />
46+
47+
## Disabled
48+
49+
Disabled form fields can be confusing to users, so use them with caution. Use a disabled color input only if you need to communicate to the user that an option that existed before is not available for choosing now. Consider not displaying the field at all or add an explanation why the field is disabled.
50+
<Story of={Stories.Disabled} />

packages/circuit-ui/components/ColorInput/ColorInput.spec.tsx

Lines changed: 82 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -28,21 +28,20 @@ import type { InputElement } from '../Input/index.js';
2828
import { ColorInput } from './ColorInput.js';
2929

3030
describe('ColorInput', () => {
31-
const baseProps = { label: 'Car color', pickerLabel: 'Pick car color' };
31+
const baseProps = { label: 'Car color' };
3232

3333
it('should merge a custom class name with the default ones', () => {
3434
const className = 'foo';
35-
const { container } = render(
36-
<ColorInput {...baseProps} inputClassName={className} />,
37-
);
38-
const input = container.querySelector('input[type="text"]');
39-
expect(input?.className).toContain(className);
35+
render(<ColorInput {...baseProps} inputClassName={className} />);
36+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
37+
const [_, textInput] = screen.getAllByLabelText(baseProps.label);
38+
expect(textInput?.className).toContain(className);
4039
});
4140

4241
it('should forward a ref', () => {
4342
const ref = createRef<InputElement>();
44-
const { container } = render(<ColorInput {...baseProps} ref={ref} />);
45-
const input = container.querySelector("input[type='color']");
43+
render(<ColorInput {...baseProps} ref={ref} />);
44+
const [input] = screen.getAllByLabelText(baseProps.label);
4645
expect(ref.current).toBe(input);
4746
});
4847

@@ -52,7 +51,7 @@ describe('ColorInput', () => {
5251
expect(actual).toHaveNoViolations();
5352
});
5453

55-
describe('Labeling', () => {
54+
describe('semantics', () => {
5655
it('should accept a custom description via aria-describedby', () => {
5756
const customDescription = 'Custom description';
5857
const customDescriptionId = 'customDescriptionId';
@@ -66,133 +65,110 @@ describe('ColorInput', () => {
6665
customDescription,
6766
);
6867
});
69-
});
68+
it('should render as disabled', async () => {
69+
render(<ColorInput {...baseProps} disabled />);
70+
const [colorInput, textInput] = screen.getAllByLabelText(baseProps.label);
71+
72+
expect(colorInput).toBeDisabled();
73+
expect(textInput).toBeDisabled();
74+
});
75+
it('should render as read-only', async () => {
76+
render(<ColorInput {...baseProps} readOnly />);
77+
const [colorInput, textInput] = screen.getAllByLabelText(baseProps.label);
78+
expect(colorInput).toBeDisabled();
79+
expect(textInput).toHaveAttribute('readonly');
80+
});
7081

71-
it('should set value and default value on both inputs', () => {
72-
const { container } = render(
73-
<ColorInput {...baseProps} defaultValue="#ff11bb" />,
74-
);
75-
const colorPicker = container.querySelector(
76-
"input[type='color']",
77-
) as HTMLInputElement;
78-
const colorInput = container.querySelector(
79-
"input[type='text']",
80-
) as HTMLInputElement;
81-
expect(colorPicker.value).toBe('#ff11bb');
82-
expect(colorInput.value).toBe('ff11bb');
82+
it('should render as required', async () => {
83+
render(<ColorInput {...baseProps} required />);
84+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
85+
const [_, textInput] = screen.getAllByLabelText(baseProps.label);
86+
expect(textInput).toBeRequired(); // text input
87+
});
8388
});
8489

85-
describe('Synchronization', () => {
86-
it('should update text input if color input changes', async () => {
87-
const { container } = render(<ColorInput {...baseProps} />);
88-
const colorPicker = container.querySelector(
89-
"input[type='color']",
90-
) as HTMLInputElement;
91-
const newValue = '#00ff00';
92-
93-
fireEvent.input(colorPicker, { target: { value: newValue } });
94-
95-
const colorInput = container.querySelector(
96-
"input[type='text']",
97-
) as HTMLInputElement;
98-
expect(colorInput.value).toBe(newValue.replace('#', ''));
90+
describe('state', () => {
91+
it('should display a default value on both inputs', () => {
92+
render(<ColorInput {...baseProps} defaultValue="#ff11bb" />);
93+
const [colorInput, textInput] = screen.getAllByLabelText(baseProps.label);
94+
95+
expect(colorInput).toHaveValue('#ff11bb');
96+
expect(textInput).toHaveValue('ff11bb');
9997
});
10098

101-
it('should update color input if text input changes', async () => {
102-
const { container } = render(<ColorInput {...baseProps} />);
103-
const colorInput = container.querySelector(
104-
"input[type='text']",
105-
) as HTMLInputElement;
106-
const newValue = '00ff00';
107-
108-
await userEvent.type(colorInput, newValue);
109-
110-
const colorPicker = container.querySelector(
111-
"input[type='color']",
112-
) as HTMLInputElement;
113-
expect(colorPicker.value).toBe(`#${newValue}`);
99+
it('should display an initial value', () => {
100+
render(<ColorInput {...baseProps} value="#ff11bb" />);
101+
102+
const [colorInput, textInput] = screen.getAllByLabelText(baseProps.label);
103+
104+
expect(colorInput).toHaveValue('#ff11bb');
105+
expect(textInput).toHaveValue('ff11bb');
106+
});
107+
108+
it('should ignore an invalid value', () => {
109+
render(<ColorInput {...baseProps} value="#fff" />);
110+
const [colorInput, textInput] = screen.getAllByLabelText(baseProps.label);
111+
112+
expect(colorInput).toHaveValue('#000000');
113+
expect(textInput).toHaveValue('fff');
114114
});
115115
});
116116

117-
describe('OnChange events', () => {
118-
it('should trigger onChange event when color picker changes', async () => {
117+
describe('user interactions', () => {
118+
const newValue = '00ff00';
119+
it('should update text input if color input changes', async () => {
119120
const onChange = vi.fn();
120-
const { container } = render(
121-
<ColorInput {...baseProps} onChange={onChange} />,
122-
);
121+
render(<ColorInput {...baseProps} onChange={onChange} />);
122+
const [colorInput, textInput] = screen.getAllByLabelText(baseProps.label);
123123

124-
const colorPicker = container.querySelector(
125-
"input[type='color']",
126-
) as HTMLInputElement;
127-
128-
fireEvent.input(colorPicker, { target: { value: '#00ff00' } });
124+
fireEvent.input(colorInput, { target: { value: `#${newValue}` } });
129125

126+
expect(textInput).toHaveValue(newValue.replace('#', ''));
130127
expect(onChange).toHaveBeenCalledTimes(1);
131128
});
132129

133-
it('should trigger onChange event when color hex input changes', async () => {
130+
it('should update color input if text input changes', async () => {
134131
const onChange = vi.fn();
135-
const { container } = render(
136-
<ColorInput {...baseProps} onChange={onChange} />,
137-
);
132+
render(<ColorInput {...baseProps} onChange={onChange} />);
133+
const [colorInput, textInput] = screen.getAllByLabelText(baseProps.label);
138134

139-
const colorInput = container.querySelector(
140-
"input[type='text']",
141-
) as HTMLInputElement;
142-
143-
await userEvent.type(colorInput, '00ff00');
135+
await userEvent.type(textInput, newValue);
144136

137+
expect(colorInput).toHaveValue(`#${newValue}`);
145138
expect(onChange).toHaveBeenCalled();
146139
});
147-
});
148140

149-
describe('Paste', () => {
150141
it('should handle paste events', async () => {
151-
const { container } = render(<ColorInput {...baseProps} />);
152-
const colorInput = container.querySelector(
153-
"input[type='text']",
154-
) as HTMLInputElement;
155-
156-
await userEvent.click(colorInput);
157-
await userEvent.paste('#00ff00');
158-
159-
const colorPicker = container.querySelector(
160-
"input[type='color']",
161-
) as HTMLInputElement;
162-
expect(colorPicker.value).toBe('#00ff00');
163-
expect(colorInput.value).toBe('00ff00');
142+
render(<ColorInput {...baseProps} />);
143+
const [colorInput, textInput] = screen.getAllByLabelText(baseProps.label);
144+
145+
await userEvent.click(textInput);
146+
await userEvent.paste(`#${newValue}`);
147+
148+
expect(colorInput).toHaveValue(`#${newValue}`);
149+
expect(textInput).toHaveValue(newValue);
164150
});
165151

166152
it('should ignore invalid paste event', async () => {
167-
const { container } = render(<ColorInput {...baseProps} />);
168-
const colorInput = container.querySelector(
169-
"input[type='text']",
170-
) as HTMLInputElement;
153+
render(<ColorInput {...baseProps} />);
154+
const [colorInput, textInput] = screen.getAllByLabelText(baseProps.label);
171155

172-
await userEvent.click(colorInput);
156+
await userEvent.click(textInput);
173157
await userEvent.paste('obviously invalid');
174158

175-
const colorPicker = container.querySelector(
176-
"input[type='color']",
177-
) as HTMLInputElement;
178-
expect(colorPicker.value).toBe('#000000');
179-
expect(colorInput.value).toBe('');
159+
expect(colorInput).toHaveValue('#000000');
160+
expect(textInput).toHaveValue('');
180161
});
181162

182163
it("should allow pasting color without '#'", async () => {
183-
const { container } = render(<ColorInput {...baseProps} />);
184-
const colorInput = container.querySelector(
185-
"input[type='text']",
186-
) as HTMLInputElement;
187-
188-
await userEvent.click(colorInput);
189-
await userEvent.paste('00ff00');
190-
191-
const colorPicker = container.querySelector(
192-
"input[type='color']",
193-
) as HTMLInputElement;
194-
expect(colorPicker.value).toBe('#00ff00');
195-
expect(colorInput.value).toBe('00ff00');
164+
render(<ColorInput {...baseProps} />);
165+
const [colorInput, textInput] = screen.getAllByLabelText(baseProps.label);
166+
167+
await userEvent.click(textInput);
168+
await userEvent.paste(newValue);
169+
170+
expect(colorInput).toHaveValue(`#${newValue}`);
171+
expect(textInput).toHaveValue(newValue);
196172
});
197173
});
198174
});

packages/circuit-ui/components/ColorInput/ColorInput.stories.tsx

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
* limitations under the License.
1414
*/
1515

16+
import { Stack } from '../../../../.storybook/components/index.js';
17+
1618
import { ColorInput, type ColorInputProps } from './ColorInput.js';
1719

1820
export default {
@@ -22,13 +24,47 @@ export default {
2224

2325
const baseArgs = {
2426
label: 'Color',
25-
pickerLabel: 'Pick color',
2627
placeholder: '#99ffbb',
2728
defaultValue: '#99ffbb',
2829
};
2930

30-
export const Base = (args: ColorInputProps) => (
31-
<ColorInput {...args} style={{ maxWidth: '250px' }} />
32-
);
31+
export const Base = (args: ColorInputProps) => <ColorInput {...args} />;
3332

3433
Base.args = baseArgs;
34+
35+
export const Optional = (args: ColorInputProps) => <ColorInput {...args} />;
36+
37+
Optional.args = { ...baseArgs, optionalLabel: 'optional' };
38+
39+
export const Readonly = (args: ColorInputProps) => <ColorInput {...args} />;
40+
41+
Readonly.args = { ...baseArgs, readOnly: true };
42+
43+
export const Disabled = (args: ColorInputProps) => <ColorInput {...args} />;
44+
45+
Disabled.args = { ...baseArgs, disabled: true };
46+
47+
export const Validations = (args: ColorInputProps) => (
48+
<Stack>
49+
<ColorInput
50+
{...args}
51+
defaultValue="#0096FF"
52+
hasWarning
53+
validationHint="Blue is not a Teletubby color :( "
54+
/>
55+
<ColorInput
56+
{...args}
57+
defaultValue="#fff"
58+
invalid
59+
validationHint="Value must be a 6 character hexadecimal color"
60+
/>
61+
<ColorInput
62+
{...args}
63+
defaultValue="#4a288d"
64+
showValid
65+
validationHint="Tinky-Winky!"
66+
/>
67+
</Stack>
68+
);
69+
70+
Validations.args = baseArgs;

0 commit comments

Comments
 (0)