Skip to content

Commit b02a763

Browse files
committed
feat(input): add raw input text and textarea components
1 parent 09fc3bf commit b02a763

File tree

14 files changed

+437
-201
lines changed

14 files changed

+437
-201
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- `@lumx/react`:
1313
- _[BREAKING]_ Drop support for React 16
1414

15+
### Added
16+
17+
- `@lumx/react`:
18+
- Added `RawInputText` and `RawInputTextarea` components. Decoration-less version of input and textarea used in
19+
`TextField` component
20+
1521
## [3.20.0][] - 2025-11-20
1622

1723
### Changed
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/* ==========================================================================
2+
Input
3+
========================================================================== */
4+
5+
.#{$lumx-base-prefix}-raw-input-text, .#{$lumx-base-prefix}-raw-input-textarea {
6+
resize: none;
7+
8+
&--theme-light {
9+
@include lumx-text-field-input-native(lumx-base-const("theme", "LIGHT"));
10+
@include lumx-text-field-input-content-color(
11+
lumx-base-const("state", "DEFAULT"),
12+
lumx-base-const("theme", "LIGHT")
13+
);
14+
15+
&::placeholder {
16+
@include lumx-text-field-input-placeholder-color(
17+
lumx-base-const("state", "DEFAULT"),
18+
lumx-base-const("theme", "LIGHT")
19+
);
20+
}
21+
}
22+
23+
&--theme-dark {
24+
@include lumx-text-field-input-native(lumx-base-const("theme", "DARK"));
25+
@include lumx-text-field-input-content-color(
26+
lumx-base-const("state", "DEFAULT"),
27+
lumx-base-const("theme", "DARK")
28+
);
29+
30+
&::placeholder {
31+
@include lumx-text-field-input-placeholder-color(
32+
lumx-base-const("state", "DEFAULT"),
33+
lumx-base-const("theme", "DARK")
34+
);
35+
}
36+
}
37+
}

packages/lumx-core/src/scss/lumx.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
@import "./components/image-block/index";
3939
@import "./components/image-lightbox/index";
4040
@import "./components/inline-list/index";
41+
@import "./components/input/index";
4142
@import "./components/input-helper/index";
4243
@import "./components/input-label/index";
4344
@import "./components/lightbox/index";
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { withValueOnChange } from '@lumx/react/stories/decorators/withValueOnChange';
2+
import { withWrapper } from '@lumx/react/stories/decorators/withWrapper';
3+
4+
import { RawInputText } from './RawInputText';
5+
6+
export default {
7+
title: 'LumX components/input/RawInputText',
8+
component: RawInputText,
9+
decorators: [withValueOnChange(), withWrapper({ style: { border: '1px dashed red' } })],
10+
};
11+
12+
export const Default = {};
13+
14+
export const WithPlaceholder = {
15+
args: {
16+
placeholder: 'Input placeholder',
17+
},
18+
};
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import React from 'react';
2+
3+
import { commonTestsSuiteRTL, SetupRenderOptions } from '@lumx/react/testing/utils';
4+
import { render } from '@testing-library/react';
5+
import userEvent from '@testing-library/user-event';
6+
import { getByClassName } from '@lumx/react/testing/utils/queries';
7+
8+
import { RawInputText, RawInputTextProps, CLASSNAME } from './RawInputText';
9+
10+
/**
11+
* Mounts the component and returns common DOM elements / data needed in multiple tests further down.
12+
*/
13+
const setup = (propsOverride: Partial<RawInputTextProps> = {}, { wrapper }: SetupRenderOptions = {}) => {
14+
const props: any = { ...propsOverride };
15+
render(<RawInputText {...props} />, { wrapper });
16+
const input = getByClassName(document.body, CLASSNAME);
17+
return { props, input };
18+
};
19+
20+
describe(`<${RawInputText.displayName}>`, () => {
21+
describe('Props', () => {
22+
it('should render default', () => {
23+
const { input } = setup();
24+
expect(input).toBeInTheDocument();
25+
expect(input?.tagName).toBe('INPUT');
26+
expect(input).toHaveAttribute('type', 'text');
27+
});
28+
29+
it('should render with custom type', () => {
30+
const { input } = setup({ type: 'number' });
31+
expect(input).toHaveAttribute('type', 'number');
32+
});
33+
});
34+
35+
describe('Events', () => {
36+
it('should call onChange with correct value when typing', async () => {
37+
const onChange = vi.fn();
38+
const name = 'inputName';
39+
const { input } = setup({ onChange, name });
40+
await userEvent.type(input as Element, 'hello');
41+
expect(onChange).toHaveBeenCalledWith('h', name, expect.any(Object));
42+
expect(onChange).toHaveBeenCalledWith('hello', name, expect.any(Object));
43+
});
44+
});
45+
46+
// Common tests suite.
47+
commonTestsSuiteRTL(setup, {
48+
baseClassName: CLASSNAME,
49+
forwardClassName: 'input',
50+
forwardAttributes: 'input',
51+
forwardRef: 'input',
52+
applyTheme: {
53+
affects: [{ element: 'input' }],
54+
viaProp: true,
55+
viaContext: true,
56+
defaultTheme: 'light',
57+
},
58+
});
59+
});
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import React, { SyntheticEvent, useRef } from 'react';
2+
3+
import classNames from 'classnames';
4+
5+
import { Theme, useTheme } from '@lumx/react';
6+
7+
import { forwardRef } from '@lumx/react/utils/react/forwardRef';
8+
import { useMergeRefs } from '@lumx/react/utils/react/mergeRefs';
9+
import { HasClassName, HasTheme } from '@lumx/react/utils/type';
10+
import { getRootClassName, handleBasicClasses } from '@lumx/core/js/utils/className';
11+
12+
type NativeInputProps = Omit<React.ComponentProps<'input'>, 'value' | 'onChange'>;
13+
14+
/**
15+
* Defines the props of the component.
16+
*/
17+
export interface RawInputTextProps extends NativeInputProps, HasTheme, HasClassName {
18+
value?: string;
19+
onChange?: (value: string, name?: string, event?: SyntheticEvent) => void;
20+
}
21+
22+
/**
23+
* Component display name.
24+
*/
25+
const COMPONENT_NAME = 'RawInputText';
26+
27+
/**
28+
* Component default class name and class prefix.
29+
*/
30+
export const CLASSNAME = getRootClassName(COMPONENT_NAME);
31+
32+
/**
33+
* Component default props.
34+
*/
35+
export const DEFAULT_PROPS: Partial<RawInputTextProps> = {
36+
type: 'text',
37+
};
38+
39+
/**
40+
* Raw input text component
41+
* (input element without any decoration)
42+
*/
43+
export const RawInputText = forwardRef<RawInputTextProps, HTMLInputElement>((props, ref) => {
44+
const defaultTheme = useTheme() || Theme.light;
45+
const { className, theme = defaultTheme, value, onChange, type = DEFAULT_PROPS.type, ...forwardedProps } = props;
46+
const textareaRef = useRef<HTMLInputElement>(null);
47+
48+
const handleChange: React.ChangeEventHandler<HTMLInputElement> = (evt) => {
49+
onChange?.(evt.target.value, evt.target.name, evt);
50+
};
51+
52+
return (
53+
<input
54+
{...forwardedProps}
55+
type={type}
56+
ref={useMergeRefs(ref, textareaRef)}
57+
className={classNames(className, handleBasicClasses({ prefix: CLASSNAME, theme }))}
58+
onChange={handleChange}
59+
value={value}
60+
/>
61+
);
62+
});
63+
RawInputText.displayName = COMPONENT_NAME;
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { withValueOnChange } from '@lumx/react/stories/decorators/withValueOnChange';
2+
import { withWrapper } from '@lumx/react/stories/decorators/withWrapper';
3+
4+
import { RawInputTextarea } from './RawInputTextarea';
5+
6+
export default {
7+
title: 'LumX components/input/RawInputTextarea',
8+
component: RawInputTextarea,
9+
decorators: [withValueOnChange(), withWrapper({ style: { border: '1px dashed red' } })],
10+
};
11+
12+
export const Default = {};
13+
14+
export const WithMinimumRows = {
15+
args: { minimumRows: 3 },
16+
};
17+
18+
export const WithPlaceholder = {
19+
args: {
20+
placeholder: 'Input placeholder',
21+
},
22+
};
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import React from 'react';
2+
3+
import { commonTestsSuiteRTL, SetupRenderOptions } from '@lumx/react/testing/utils';
4+
import { render } from '@testing-library/react';
5+
import userEvent from '@testing-library/user-event';
6+
import { getByClassName } from '@lumx/react/testing/utils/queries';
7+
8+
import { RawInputTextarea, RawInputTextareaProps, CLASSNAME, DEFAULT_PROPS } from './RawInputTextarea';
9+
10+
/**
11+
* Mounts the component and returns common DOM elements / data needed in multiple tests further down.
12+
*/
13+
const setup = (propsOverride: Partial<RawInputTextareaProps> = {}, { wrapper }: SetupRenderOptions = {}) => {
14+
const props: any = { ...propsOverride };
15+
render(<RawInputTextarea {...props} />, { wrapper });
16+
const textarea = getByClassName(document.body, CLASSNAME);
17+
return { props, textarea };
18+
};
19+
20+
describe(`<${RawInputTextarea.displayName}>`, () => {
21+
describe('Props', () => {
22+
it('should render default', () => {
23+
const { textarea } = setup();
24+
expect(textarea).toBeInTheDocument();
25+
expect(textarea?.tagName).toBe('TEXTAREA');
26+
expect(textarea).toHaveAttribute('rows', String(DEFAULT_PROPS.minimumRows));
27+
});
28+
29+
it('should render with custom minimumRows', () => {
30+
const { textarea } = setup({ minimumRows: 5 });
31+
expect(textarea).toHaveAttribute('rows', '5');
32+
});
33+
34+
it('should render with placeholder', () => {
35+
const { textarea } = setup({ placeholder: 'Test placeholder' });
36+
expect(textarea).toHaveAttribute('placeholder', 'Test placeholder');
37+
});
38+
});
39+
40+
describe('Events', () => {
41+
it('should call onChange with correct value when typing', async () => {
42+
const onChange = vi.fn();
43+
const name = 'textareaName';
44+
const { textarea } = setup({ onChange, name });
45+
await userEvent.type(textarea as Element, 'hello');
46+
expect(onChange).toHaveBeenCalledWith('h', name, expect.any(Object));
47+
expect(onChange).toHaveBeenCalledWith('hello', name, expect.any(Object));
48+
});
49+
});
50+
51+
// Common tests suite.
52+
commonTestsSuiteRTL(setup, {
53+
baseClassName: CLASSNAME,
54+
forwardClassName: 'textarea',
55+
forwardAttributes: 'textarea',
56+
forwardRef: 'textarea',
57+
applyTheme: {
58+
affects: [{ element: 'textarea' }],
59+
viaProp: true,
60+
viaContext: true,
61+
defaultTheme: 'light',
62+
},
63+
});
64+
});
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import React, { SyntheticEvent, useRef } from 'react';
2+
3+
import classNames from 'classnames';
4+
5+
import { Theme, useTheme } from '@lumx/react';
6+
import { forwardRef } from '@lumx/react/utils/react/forwardRef';
7+
import { useMergeRefs } from '@lumx/react/utils/react/mergeRefs';
8+
import type { HasClassName, HasTheme } from '@lumx/core/js/types';
9+
import { handleBasicClasses, getRootClassName } from '@lumx/core/js/utils';
10+
11+
import { useFitRowsToContent } from './useFitRowsToContent';
12+
13+
type NativeTextareaProps = React.ComponentProps<'textarea'>;
14+
15+
/**
16+
* Defines the props of the component.
17+
*/
18+
export interface RawInputTextareaProps extends Omit<NativeTextareaProps, 'value' | 'onChange'>, HasTheme, HasClassName {
19+
minimumRows?: number;
20+
value?: string;
21+
onChange?: (value: string, name?: string, event?: SyntheticEvent) => void;
22+
}
23+
24+
/**
25+
* Component display name.
26+
*/
27+
const COMPONENT_NAME = 'RawInputTextarea';
28+
29+
/**
30+
* Component default class name and class prefix.
31+
*/
32+
export const CLASSNAME = getRootClassName(COMPONENT_NAME);
33+
34+
/**
35+
* Component default props.
36+
*/
37+
export const DEFAULT_PROPS: Partial<RawInputTextareaProps> = {
38+
minimumRows: 2,
39+
};
40+
41+
/**
42+
* Raw input text area component
43+
* (textarea element without any decoration)
44+
*/
45+
export const RawInputTextarea = forwardRef<Omit<RawInputTextareaProps, 'type'>, HTMLTextAreaElement>((props, ref) => {
46+
const defaultTheme = useTheme() || Theme.light;
47+
const {
48+
className,
49+
theme = defaultTheme,
50+
minimumRows = DEFAULT_PROPS.minimumRows as number,
51+
value,
52+
onChange,
53+
...forwardedProps
54+
} = props;
55+
const textareaRef = useRef<HTMLTextAreaElement>(null);
56+
57+
const rows = useFitRowsToContent(minimumRows, textareaRef, value);
58+
59+
const handleChange: React.ChangeEventHandler<HTMLTextAreaElement> = React.useCallback(
60+
(evt) => {
61+
onChange?.(evt.target.value, evt.target.name, evt);
62+
},
63+
[onChange],
64+
);
65+
66+
return (
67+
<textarea
68+
className={classNames(className, handleBasicClasses({ prefix: CLASSNAME, theme }))}
69+
ref={useMergeRefs(ref, textareaRef)}
70+
{...forwardedProps}
71+
onChange={handleChange}
72+
value={value}
73+
rows={rows}
74+
/>
75+
);
76+
});
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { RawInputText, type RawInputTextProps } from './RawInputText';
2+
export { RawInputTextarea, type RawInputTextareaProps } from './RawInputTextarea';

0 commit comments

Comments
 (0)