Skip to content

Commit 4572fca

Browse files
feat: add switcher and correct styles (Issue #14) (#22)
1 parent 2d85c8f commit 4572fca

File tree

15 files changed

+568
-178
lines changed

15 files changed

+568
-178
lines changed

package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"name": "@epam/ai-dial-ui-kit",
33
"version": "0.0.1",
44
"type": "module",
5+
"license": "Apache-2.0",
56
"types": "dist/src/index.d.ts",
67
"engines": {
78
"node": ">=22.2.0",

src/components/Input/Input.spec.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ describe('Dial UI Kit :: DialInput', () => {
9191
<DialInput elementId="test-input" placeholder="Invalid input" invalid />,
9292
);
9393
const input = getByPlaceholderText('Invalid input');
94-
const container = input.parentElement;
94+
const container = input.parentElement?.parentElement;
9595
expect(input).toHaveClass('border-0 bg-transparent');
9696
expect(container).toHaveClass('dial-input-error');
9797
});

src/components/Input/Input.tsx

Lines changed: 57 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
import classNames from 'classnames';
2-
import type { FC } from 'react';
2+
import type { ChangeEvent, FC, KeyboardEvent, WheelEvent } from 'react';
33

44
import { DialIcon } from '@/components/Icon/Icon';
55
import { DialTooltip } from '@/components/Tooltip/Tooltip';
66
import type { InputBaseProps } from '@/models/field-control-props';
7+
import { handleKeyDown } from './utils';
78

89
export interface DialInputProps extends InputBaseProps {
910
type?: string;
1011
containerCssClass?: string;
1112
cssClass?: string;
1213
hideBorder?: boolean;
14+
tooltipTriggerClassName?: string;
1315
onChange?: (value: string) => void;
1416
}
1517

@@ -45,6 +47,7 @@ export interface DialInputProps extends InputBaseProps {
4547
* @param [prefix] - Text to display inside the input on the left
4648
* @param [suffix] - Text to display inside the input on the right
4749
* @param [textBeforeInput] - Text to display before the input in a separate field
50+
* @param [tooltipTriggerClassName] - Additional CSS classes to apply to the tooltip
4851
* @param [textAfterInput] - Text to display after the input in a separate field
4952
*/
5053
export const DialInput: FC<DialInputProps> = ({
@@ -56,6 +59,7 @@ export const DialInput: FC<DialInputProps> = ({
5659
placeholder = '',
5760
cssClass = '',
5861
containerCssClass,
62+
tooltipTriggerClassName,
5963
type = 'text',
6064
disabled,
6165
readonly,
@@ -68,6 +72,41 @@ export const DialInput: FC<DialInputProps> = ({
6872
textBeforeInput,
6973
textAfterInput,
7074
}) => {
75+
const handleWheel = (e: WheelEvent<HTMLInputElement>) =>
76+
(e.target as HTMLInputElement).blur();
77+
78+
const isNumericInput =
79+
type === 'number' || min !== undefined || max !== undefined;
80+
81+
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
82+
handleKeyDown(e, type, min, max);
83+
};
84+
85+
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
86+
const newValue = event.currentTarget.value;
87+
88+
if (isNumericInput && newValue !== '') {
89+
const numericValue = parseFloat(newValue);
90+
91+
// If it's not a valid number (except for partial inputs like "-" or ".")
92+
if (isNaN(numericValue) && newValue !== '-' && newValue !== '.') {
93+
return;
94+
}
95+
96+
// Check range constraints for complete numbers
97+
if (!isNaN(numericValue)) {
98+
if (min !== undefined && numericValue < min) {
99+
return;
100+
}
101+
if (max !== undefined && numericValue > max) {
102+
return;
103+
}
104+
}
105+
}
106+
107+
onChange?.(newValue);
108+
};
109+
71110
return (
72111
<div
73112
className={classNames(
@@ -96,19 +135,23 @@ export const DialInput: FC<DialInputProps> = ({
96135
{prefix && <p className="text-secondary dial-small pl-2"> {prefix}</p>}
97136
<DialIcon icon={iconBeforeInput} className="pl-2" />
98137

99-
<input
100-
type={type}
101-
autoComplete="new-password"
102-
id={elementId}
103-
placeholder={placeholder}
104-
value={value ?? ''}
105-
title={value ? String(value) : ''}
106-
disabled={disabled}
107-
min={min}
108-
max={max}
109-
className={classNames('border-0 bg-transparent px-2', cssClass)}
110-
onChange={(event) => !readonly && onChange?.(event.currentTarget.value)}
111-
/>
138+
<DialTooltip tooltip={value} triggerClassName={tooltipTriggerClassName}>
139+
<input
140+
type={type}
141+
autoComplete="off"
142+
id={elementId}
143+
placeholder={placeholder}
144+
value={value ?? ''}
145+
title={value ? String(value) : ''}
146+
disabled={disabled}
147+
className={classNames('border-0 bg-transparent px-2', cssClass)}
148+
onChange={(event) => !readonly && handleChange?.(event)}
149+
onKeyDown={onKeyDown}
150+
onWheel={handleWheel}
151+
min={min}
152+
max={max}
153+
/>
154+
</DialTooltip>
112155

113156
<DialIcon icon={iconAfterInput} className="pr-2" />
114157
{suffix && <p className="text-secondary dial-small pr-2"> {suffix}</p>}

src/components/Input/utils.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import type { KeyboardEvent } from 'react';
2+
3+
const ALLOWED_INPUT_KEYS = [
4+
'ArrowLeft',
5+
'ArrowRight',
6+
'ArrowUp',
7+
'ArrowDown',
8+
'Backspace',
9+
'Delete',
10+
'Tab',
11+
'Enter',
12+
'Escape',
13+
'Home',
14+
'End',
15+
];
16+
17+
export const handleKeyDown = (
18+
e: KeyboardEvent<HTMLInputElement>,
19+
type?: string,
20+
min?: number,
21+
max?: number,
22+
) => {
23+
const isNumericInput =
24+
type === 'number' || min !== undefined || max !== undefined;
25+
26+
if (!isNumericInput) return;
27+
28+
if (ALLOWED_INPUT_KEYS.includes(e.key)) {
29+
return;
30+
}
31+
32+
// Allow minus sign only at the beginning and if min allows negative numbers
33+
if (
34+
e.key === '-' &&
35+
e.currentTarget.selectionStart === 0 &&
36+
(min === undefined || min < 0)
37+
) {
38+
return;
39+
}
40+
41+
// Allow decimal point for number inputs (but not if it already exists)
42+
if (
43+
e.key === '.' &&
44+
type === 'number' &&
45+
!e.currentTarget.value.includes('.')
46+
) {
47+
return;
48+
}
49+
50+
// Only allow numeric characters
51+
if (!/^[0-9]$/.test(e.key)) {
52+
e.preventDefault();
53+
return;
54+
}
55+
56+
// Check if the resulting value would be within range
57+
if (min !== undefined || max !== undefined) {
58+
const currentValue = e.currentTarget.value;
59+
const cursorPosition = e.currentTarget.selectionStart || 0;
60+
const newValue =
61+
currentValue.slice(0, cursorPosition) +
62+
e.key +
63+
currentValue.slice(cursorPosition);
64+
const numericValue = parseFloat(newValue);
65+
66+
if (!isNaN(numericValue)) {
67+
if (min !== undefined && numericValue < min) {
68+
e.preventDefault();
69+
return;
70+
}
71+
if (max !== undefined && numericValue > max) {
72+
e.preventDefault();
73+
return;
74+
}
75+
}
76+
}
77+
};
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { render, screen, fireEvent } from '@testing-library/react';
2+
import { describe, it, expect, vi } from 'vitest';
3+
import { DialSwitch } from './Switch';
4+
5+
describe('Dial UI Kit :: DialSwitch', () => {
6+
it('renders with title', () => {
7+
render(<DialSwitch title="Test Switch" switchId="switch1" />);
8+
expect(screen.getByText('Test Switch')).toBeInTheDocument();
9+
});
10+
11+
it('calls onChange with toggled value', () => {
12+
const onChange = vi.fn();
13+
render(
14+
<DialSwitch
15+
title="Test Switch"
16+
switchId="switch2"
17+
isOn={false}
18+
onChange={onChange}
19+
/>,
20+
);
21+
const checkbox = screen.getByRole('checkbox');
22+
fireEvent.click(checkbox);
23+
expect(onChange).toHaveBeenCalledWith(true);
24+
});
25+
26+
it('is disabled when disabled prop is true', () => {
27+
const onChange = vi.fn();
28+
render(
29+
<DialSwitch
30+
title="Disabled Switch"
31+
switchId="switch3"
32+
disabled
33+
onChange={onChange}
34+
/>,
35+
);
36+
const checkbox = screen.getByRole('checkbox');
37+
expect(checkbox).toBeDisabled();
38+
});
39+
});
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import type { Meta, StoryObj } from '@storybook/react-vite';
2+
import { useState } from 'react';
3+
import { DialSwitch, type DialSwitchProps } from './Switch';
4+
5+
const InteractiveSwitch = (args: DialSwitchProps) => {
6+
const [value, setValue] = useState(args.isOn);
7+
8+
return (
9+
<div className="w-full text-primary">
10+
<DialSwitch
11+
{...args}
12+
isOn={value}
13+
onChange={(newValue) => setValue(newValue)}
14+
/>
15+
</div>
16+
);
17+
};
18+
19+
const meta = {
20+
title: 'Components/Switch',
21+
component: DialSwitch,
22+
tags: ['switch'],
23+
parameters: {
24+
layout: 'centered',
25+
docs: {
26+
description: {
27+
component: 'A flexible switch component with consistent styling.',
28+
},
29+
},
30+
},
31+
argTypes: {
32+
switchId: {
33+
control: 'text',
34+
description: 'Unique identifier for the switch element',
35+
},
36+
title: {
37+
control: 'text',
38+
description: 'The title/label text to display for the switch',
39+
},
40+
isOn: {
41+
control: 'boolean',
42+
description: 'The current value of the switch',
43+
},
44+
disabled: {
45+
control: 'boolean',
46+
description: 'Whether the switch is disabled',
47+
},
48+
onChange: {
49+
action: 'changed',
50+
control: false,
51+
description: 'Callback function called when the switch value changes',
52+
},
53+
},
54+
} satisfies Meta<typeof DialSwitch>;
55+
56+
export default meta;
57+
type Story = StoryObj<typeof meta>;
58+
59+
export const Default: Story = {
60+
render: InteractiveSwitch,
61+
args: {
62+
switchId: 'default-switch',
63+
title: 'Enable feature',
64+
},
65+
};
66+
67+
export const WithActiveValue: Story = {
68+
render: InteractiveSwitch,
69+
args: {
70+
switchId: 'default-switch',
71+
title: 'Enable feature',
72+
isOn: true,
73+
},
74+
};
75+
76+
export const Disabled: Story = {
77+
render: InteractiveSwitch,
78+
args: {
79+
switchId: 'default-switch',
80+
title: 'Enable feature',
81+
isOn: true,
82+
disabled: true,
83+
},
84+
};
85+
86+
export const AllVariants: Story = {
87+
args: {
88+
switchId: 'all-variants-textarea',
89+
},
90+
render: () => (
91+
<div className="min-w-[800px] p-8">
92+
<div className="grid grid-cols-3 gap-6">
93+
{/* Default State */}
94+
<div>
95+
<div className="text-primary font-semibold mb-2">Default</div>
96+
<InteractiveSwitch switchId="default-switch" title="Switch" />
97+
</div>
98+
99+
{/* Disabled State for active Switch */}
100+
<div>
101+
<div className="text-primary font-semibold mb-2">
102+
Disabled for active Switch
103+
</div>
104+
<InteractiveSwitch
105+
switchId="disabled-switch"
106+
title="Disabled Switch"
107+
disabled={true}
108+
isOn={true}
109+
/>
110+
</div>
111+
112+
{/* Disabled State for not active Switch */}
113+
<div>
114+
<div className="text-primary font-semibold mb-2">
115+
Disabled for not active Switch
116+
</div>
117+
<InteractiveSwitch
118+
switchId="disabled-switch"
119+
title="Disabled Switch"
120+
disabled={true}
121+
isOn={false}
122+
/>
123+
</div>
124+
</div>
125+
</div>
126+
),
127+
};

0 commit comments

Comments
 (0)