diff --git a/components/input/.npmignore b/components/input/.npmignore new file mode 100644 index 00000000..5e85453c --- /dev/null +++ b/components/input/.npmignore @@ -0,0 +1,4 @@ +src +rollup.config.js +tsconfig.json +tsconfig.build.json diff --git a/components/input/README.md b/components/input/README.md new file mode 100644 index 00000000..2edd2745 --- /dev/null +++ b/components/input/README.md @@ -0,0 +1,55 @@ +# `@byndyusoft-ui/input` + +The `input` component is a React component designed to provide a flexible and customizable input field for your applications. + +## Installation + +```sh +npm i @byndyusoft-ui/input +# or +yarn add @byndyusoft-ui/input +``` + +### Customization with CSS Variables + +You can customize the appearance of the input component by overriding the following CSS variables: + +```css +:root { + --input-height-s: 1.5rem; + --input-height-m: 2rem; + --input-height-l: 2.5rem; + --input-height-xl: 3rem; + + --input-font-size-s: 0.75rem; + --input-font-size-m: 0.875rem; + --input-font-size-l: 1rem; + --input-font-size-xl: 1.25rem; + + --input-border-radius: 0.375rem; + --input-padding-x: 0.5rem; + --input-transition: box-shadow ease 200ms, border ease 200ms; + --input-disabled-opacity: 0.5; + --input-container-gap: 0.25rem; + + --input-main-color: #343434; + --input-focus-color: #000000; + --input-invalid-color: #ff0000; + + --input-line-border: 1px solid var(--input-main-color); + --input-line-focus-border: 1px solid var(--input-focus-color); + --input-line-focus-box-shadow: 0 -1px 0 0 var(--input-focus-color) inset; + --input-invalid-line-border: 1px solid var(--input-invalid-color); + --input-invalid-line-box-shadow: 0 -1px 0 0 var(--input-invalid-color) inset; + + --input-outline-border: 1px solid var(--input-main-color); + --input-outline-focus-border: 1px solid var(--input-focus-color); + --input-outline-focus-box-shadow: 0 0 0 1px var(--input-focus-color) inset; + --input-invalid-outline-border: 1px solid var(--input-invalid-color); + --input-invalid-outline-box-shadow: 0 0 0 1px var(--input-invalid-color) inset; +} +``` + + +## License +This project is licensed under the ISC License. diff --git a/components/input/package.json b/components/input/package.json new file mode 100644 index 00000000..ac21c694 --- /dev/null +++ b/components/input/package.json @@ -0,0 +1,36 @@ +{ + "name": "@byndyusoft-ui/input", + "version": "0.1.0", + "description": "Byndyusoft UI Input React Component", + "keywords": [ + "byndyusoft", + "byndyusoft-ui", + "react", + "input" + ], + "author": "Fomin Gleb ", + "homepage": "https://github.com/Byndyusoft/ui/tree/master/components/Input#readme", + "license": "ISC", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "repository": { + "type": "git", + "url": "git+https://github.com/Byndyusoft/ui.git" + }, + "scripts": { + "build": "tsc --project tsconfig.build.json", + "clean": "rimraf dist", + "lint": "eslint src --config ../../eslint.config.js", + "test": "jest --config ../../jest.config.js --roots components/input/src" + }, + "bugs": { + "url": "https://github.com/Byndyusoft/ui/issues" + }, + "publishConfig": { + "access": "public" + }, + "peerDependencies": { + "react": ">=17", + "classnames": "^2.3.1" + } +} diff --git a/components/input/rollup.config.js b/components/input/rollup.config.js new file mode 100644 index 00000000..b14566e7 --- /dev/null +++ b/components/input/rollup.config.js @@ -0,0 +1,15 @@ +import typescript from '@rollup/plugin-typescript'; +import baseConfig from '../../rollup.base.config'; + +export default { + ...baseConfig, + input: ['src/index.ts'], + plugins: [ + ...baseConfig.plugins, + typescript({ + tsconfig: './tsconfig.json', + module: 'ESNext', + exclude: ['src/*.stories.tsx', 'src/*.tests.tsx', 'node_modules'] + }) + ] +}; diff --git a/components/input/src/Input.module.css b/components/input/src/Input.module.css new file mode 100644 index 00000000..efa56649 --- /dev/null +++ b/components/input/src/Input.module.css @@ -0,0 +1,121 @@ +:root { + --input-height-s: 1.5rem; + --input-height-m: 2rem; + --input-height-l: 2.5rem; + --input-height-xl: 3rem; + + --input-font-size-s: 0.75rem; + --input-font-size-m: 0.875rem; + --input-font-size-l: 1rem; + --input-font-size-xl: 1.25rem; + + --input-border-radius: 0.375rem; + --input-padding-x: 0.5rem; + --input-transition: box-shadow ease 200ms, border ease 200ms; + --input-disabled-opacity: 0.5; + --input-container-gap: 0.25rem; + + --input-main-color: #343434; + --input-focus-color: #000000; + --input-invalid-color: #ff0000; + + --input-line-border: 1px solid var(--input-main-color); + --input-line-focus-border: 1px solid var(--input-focus-color); + --input-line-focus-box-shadow: 0 -1px 0 0 var(--input-focus-color) inset; + --input-invalid-line-border: 1px solid var(--input-invalid-color); + --input-invalid-line-box-shadow: 0 -1px 0 0 var(--input-invalid-color) inset; + + --input-outline-border: 1px solid var(--input-main-color); + --input-outline-focus-border: 1px solid var(--input-focus-color); + --input-outline-focus-box-shadow: 0 0 0 1px var(--input-focus-color) inset; + --input-invalid-outline-border: 1px solid var(--input-invalid-color); + --input-invalid-outline-box-shadow: 0 0 0 1px var(--input-invalid-color) inset; +} + +.input_container { + box-sizing: border-box; + display: flex; + gap: var(--input-container-gap); + align-items: center; + overflow: hidden; + background: none; + outline: none; + transition: var(--input-transition); +} + +.disabled { + pointer-events: none; + opacity: var(--input-disabled-opacity); +} + +.input { + width: 100%; + border: none; + outline: none; + background: none; +} + +.s, .s .input { + height: var(--input-height-s); + font-size: var(--input-font-size-s); +} + + +.m, .m .input { + height: var(--input-height-m); + font-size: var(--input-font-size-m); +} + +.l, .l .input { + height: var(--input-height-l); + font-size: var(--input-font-size-l); +} + +.xl, .xl .input { + height: var(--input-height-xl); + font-size: var(--input-font-size-xl); +} + +.outline { + border: var(--input-outline-border); + border-radius: var(--input-border-radius); + padding-left: var(--input-padding-x); + padding-right: var(--input-padding-x); + +} + +.outline:focus-within { + border: var(--input-outline-focus-border); + box-shadow: var(--input-outline-focus-box-shadow); +} + +.line { + border-bottom: var(--input-line-border); +} + +.line:focus-within { + border-bottom: var(--input-line-focus-border); + box-shadow: var(--input-line-focus-box-shadow); +} + +.unstyled { + border: none; +} + +.invalid.outline { + border: var(--input-invalid-outline-border); +} + +.invalid.outline:focus-within { + border: var(--input-invalid-outline-border); + box-shadow: var(--input-invalid-outline-box-shadow); +} + +.invalid.line { + border-bottom: var(--input-invalid-line-border); +} + +.invalid.line:focus-within { + border-bottom: var(--input-invalid-line-border); + box-shadow: var(--input-invalid-line-box-shadow); +} diff --git a/components/input/src/Input.tsx b/components/input/src/Input.tsx new file mode 100644 index 00000000..eff35041 --- /dev/null +++ b/components/input/src/Input.tsx @@ -0,0 +1,51 @@ +import React, { forwardRef } from 'react'; +import cn from 'classnames'; +import { IInputProps } from './input.types'; +import styles from './Input.module.css'; + +const Input = forwardRef((props, ref) => { + const { + size = 'l', + type = 'text', + variant = 'unstyled', + rightComponent, + leftComponent, + disabled, + isInvalid, + className, + style, + inputClassName, + inputStyle, + ...otherProps + } = props; + + const inputContainerClasses = [ + styles.input_container, + styles[variant], + styles[size], + { [styles.disabled]: Boolean(disabled) }, + { [styles.invalid]: Boolean(isInvalid) }, + className + ]; + + const inputClasses = [styles.input, inputClassName]; + + return ( +
+ {leftComponent} + + {rightComponent} +
+ ); +}); + +Input.displayName = 'Input'; + +export default Input; diff --git a/components/input/src/__stories__/Input.docs.mdx b/components/input/src/__stories__/Input.docs.mdx new file mode 100644 index 00000000..b399dbb8 --- /dev/null +++ b/components/input/src/__stories__/Input.docs.mdx @@ -0,0 +1,55 @@ +import { Meta, Markdown, Canvas, Source, ArgsTable } from '@storybook/blocks'; +import Readme from '../../README.md'; +import Input from '../Input'; +import * as InputStories from './Input.stories'; + + + +{Readme} + +## Usage + +To use the component in your project you must: + +1. Import the component where you need it: + + + +2. **Input Sizes**: Customize the size of the input field. Available sizes are `s`, `m`, `l`, `xl`. + + + +3. **Input Variants**: Choose from different input variants such as `outline`, `line`, `unstyled`. + + + +4. **Side Components**: Add components to the left or right side of the input field. + + + +5. **Invalid State**: Handle invalid input states by setting the `isInvalid` prop. + + + + +## Props + +Inherited from `InputHTMLAttributes` + + diff --git a/components/input/src/__stories__/Input.stories.module.css b/components/input/src/__stories__/Input.stories.module.css new file mode 100644 index 00000000..b5950092 --- /dev/null +++ b/components/input/src/__stories__/Input.stories.module.css @@ -0,0 +1,8 @@ +.template_wrapper { + padding: 20px; + margin: auto; + width: 250px; + display: flex; + flex-direction: column; + gap: 16px; +} diff --git a/components/input/src/__stories__/Input.stories.tsx b/components/input/src/__stories__/Input.stories.tsx new file mode 100644 index 00000000..dd052000 --- /dev/null +++ b/components/input/src/__stories__/Input.stories.tsx @@ -0,0 +1,66 @@ +import React, { ReactNode } from 'react'; +import { StoryObj } from '@storybook/react'; +import Input from '../Input'; +import { IInputProps } from '../input.types'; +import styles from './Input.stories.module.css'; + +const TemplateWrapper = ({ children }: { children: ReactNode }) => { + return
{children}
; +}; + +const InputSizeTemplate = (args: IInputProps) => ( + + + + + + +); + +const InputVariantTemplate = (args: IInputProps) => ( + + + + + +); + +const InputSideComponentsTemplate = (args: IInputProps) => ( + + + + + +); + +const InputInvalidTemplate = (args: IInputProps) => ( + + + + + +); + +export const InputSizeStory: StoryObj = { + name: 'InputSize', + render: InputSizeTemplate +}; + +export const InputVariantStory: StoryObj = { + name: 'InputVariant', + render: InputVariantTemplate +}; + +export const InputSideComponentsStory: StoryObj = { + name: 'InputSideComponents', + render: InputSideComponentsTemplate +}; + +export const InputInvalidTemplateStory: StoryObj = { + name: 'InputInvalidTemplate', + render: InputInvalidTemplate +}; + +export default { + title: 'components/Input' +}; diff --git a/components/input/src/__tests__/Input.tests.tsx b/components/input/src/__tests__/Input.tests.tsx new file mode 100644 index 00000000..70a48993 --- /dev/null +++ b/components/input/src/__tests__/Input.tests.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import Input from '../Input'; +import { IInputProps } from '../input.types'; + +const setup = (props: IInputProps = {}) => render(); + +describe('Input Component', () => { + test('renders input element', () => { + const { getByRole } = setup(); + const inputElement = getByRole('textbox'); + expect(inputElement).toBeInTheDocument(); + }); + + test('applies the correct classes based on props', () => { + const { getByRole } = setup({ + size: 'l', + variant: 'line', + className: 'custom-class', + inputClassName: 'custom-input-class' + }); + const inputContainer = getByRole('textbox').closest('div'); + expect(inputContainer).toHaveClass('input_container', 'line', 'l', 'custom-class'); + expect(getByRole('textbox')).toHaveClass('input', 'custom-input-class'); + }); + + test('applies disabled state', () => { + const { getByRole } = setup({ disabled: true }); + const inputElement = getByRole('textbox'); + expect(inputElement).toBeDisabled(); + expect(inputElement.closest('div')).toHaveClass('disabled'); + }); + + test('applies invalid state', () => { + const { getByRole } = setup({ isInvalid: true }); + const inputContainer = getByRole('textbox').closest('div'); + expect(inputContainer).toHaveClass('invalid'); + }); + + test('renders left and right components', () => { + const LeftComponent =
Left
; + const RightComponent =
Right
; + + const { getByTestId } = setup({ leftComponent: LeftComponent, rightComponent: RightComponent }); + + expect(getByTestId('left-component')).toBeInTheDocument(); + expect(getByTestId('right-component')).toBeInTheDocument(); + }); + + test('applies styles correctly', () => { + const containerStyle = { padding: '10px' }; + const inputStyle = { color: 'red' }; + const { getByRole } = setup({ style: containerStyle, inputStyle: inputStyle }); + const inputContainer = getByRole('textbox').closest('div'); + const inputElement = getByRole('textbox'); + + expect(inputContainer).toHaveStyle('padding: 10px'); + expect(inputElement).toHaveStyle('color: red'); + }); + + test('handles input change', async () => { + const handleChange = jest.fn(); + const { getByRole } = setup({ onChange: handleChange }); + const inputElement = getByRole('textbox') as HTMLInputElement; + + await userEvent.type(inputElement, 'test'); + + expect(handleChange).toHaveBeenCalledTimes(4); + expect(inputElement).toHaveValue('test'); + }); +}); diff --git a/components/input/src/index.ts b/components/input/src/index.ts new file mode 100644 index 00000000..a8a522c4 --- /dev/null +++ b/components/input/src/index.ts @@ -0,0 +1,2 @@ +export { type IInputProps } from './input.types'; +export { default } from './Input'; diff --git a/components/input/src/input.types.ts b/components/input/src/input.types.ts new file mode 100644 index 00000000..d6c0290a --- /dev/null +++ b/components/input/src/input.types.ts @@ -0,0 +1,18 @@ +import { CSSProperties, InputHTMLAttributes, ReactNode } from 'react'; + +type THTMLInputProps = Omit, 'size'>; + +type TInputSize = 's' | 'm' | 'l' | 'xl'; + +type TInputVariant = 'outline' | 'line' | 'unstyled'; + +export interface IInputProps extends THTMLInputProps { + size?: TInputSize; + variant?: TInputVariant; + className?: string; + rightComponent?: ReactNode; + leftComponent?: ReactNode; + isInvalid?: boolean; + inputClassName?: string; + inputStyle?: CSSProperties; +} diff --git a/components/input/tsconfig.build.json b/components/input/tsconfig.build.json new file mode 100644 index 00000000..8dd1e506 --- /dev/null +++ b/components/input/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "@byndyusoft-ui/modals-provider/tsconfig.json", + "exclude": ["src/*.tests.ts", "src/*.stories.*"] +} diff --git a/components/input/tsconfig.json b/components/input/tsconfig.json new file mode 100644 index 00000000..98f42aee --- /dev/null +++ b/components/input/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationDir": "dist", + "outDir": "dist", + "module": "commonjs" + }, + "include": ["../../types.d.ts", "src"] +} diff --git a/package-lock.json b/package-lock.json index e5202ede..94a6cfb7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -103,6 +103,22 @@ "react": ">=17" } }, + "components/input": { + "name": "@byndyusoft-ui/input", + "version": "0.1.0", + "license": "Apache-2.0", + "peerDependencies": { + "react": ">=17" + } + }, + "components/input": { + "version": "0.1.0", + "license": "ISC", + "peerDependencies": { + "classnames": "^2.3.1", + "react": ">=17" + } + }, "components/modals-provider": { "name": "@byndyusoft-ui/modals-provider", "version": "0.1.1", @@ -5235,7 +5251,6 @@ }, "node_modules/classnames": { "version": "2.3.1", - "dev": true, "license": "MIT" }, "node_modules/clean-regexp": {