From a33b55c50cfe54f6e9d3459954e5ec4ccbb92660 Mon Sep 17 00:00:00 2001 From: Gleb Fomin Date: Wed, 12 Feb 2025 12:31:48 +0500 Subject: [PATCH 1/3] feat(components): added input --- components/input/.npmignore | 3 + components/input/README.md | 55 ++++++++ components/input/package.json | 35 +++++ components/input/rollup.config.js | 15 +++ components/input/src/Input.module.css | 122 ++++++++++++++++++ components/input/src/Input.tsx | 51 ++++++++ .../input/src/__stories__/Input.docs.mdx | 55 ++++++++ .../src/__stories__/Input.stories.module.css | 23 ++++ .../input/src/__stories__/Input.stories.tsx | 66 ++++++++++ .../input/src/__tests__/Input.tests.tsx | 74 +++++++++++ components/input/src/index.ts | 2 + components/input/src/input.types.ts | 18 +++ components/input/tsconfig.build.json | 4 + components/input/tsconfig.json | 10 ++ 14 files changed, 533 insertions(+) create mode 100644 components/input/.npmignore create mode 100644 components/input/README.md create mode 100644 components/input/package.json create mode 100644 components/input/rollup.config.js create mode 100644 components/input/src/Input.module.css create mode 100644 components/input/src/Input.tsx create mode 100644 components/input/src/__stories__/Input.docs.mdx create mode 100644 components/input/src/__stories__/Input.stories.module.css create mode 100644 components/input/src/__stories__/Input.stories.tsx create mode 100644 components/input/src/__tests__/Input.tests.tsx create mode 100644 components/input/src/index.ts create mode 100644 components/input/src/input.types.ts create mode 100644 components/input/tsconfig.build.json create mode 100644 components/input/tsconfig.json diff --git a/components/input/.npmignore b/components/input/.npmignore new file mode 100644 index 00000000..2b07fde8 --- /dev/null +++ b/components/input/.npmignore @@ -0,0 +1,3 @@ +src +rollup.config.js +tsconfig.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..940a8b9b --- /dev/null +++ b/components/input/package.json @@ -0,0 +1,35 @@ +{ + "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" + } +} 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..b8449d9b --- /dev/null +++ b/components/input/src/Input.module.css @@ -0,0 +1,122 @@ +: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..9f2265e4 --- /dev/null +++ b/components/input/src/Input.tsx @@ -0,0 +1,51 @@ +import React, { forwardRef } from 'react'; +import cn from 'classnames'; +import { InputProps } 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..7dc434d5 --- /dev/null +++ b/components/input/src/__stories__/Input.stories.module.css @@ -0,0 +1,23 @@ +.template_wrapper { + padding: 20px; + margin: auto; + width: 250px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.container { + margin: auto; + padding: 20px; + width: 40%; + display: flex; + flex-direction: column; + gap: 20px; +} + +.col { + 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..52bac67d --- /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 { InputProps } from '../input.types'; +import styles from './Input.stories.module.css'; + +const TemplateWrapper = ({ children }: { children: ReactNode }) => { + return
{children}
; +}; + +const InputSizeTemplate = (args: InputProps) => ( + + + + + + +); + +const InputVariantTemplate = (args: InputProps) => ( + + + + + +); + +const InputSideComponentsTemplate = (args: InputProps) => ( + + + + + +); + +const InputInvalidTemplate = (args: InputProps) => ( + + + + + +); + +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..7f8ce497 --- /dev/null +++ b/components/input/src/__tests__/Input.tests.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import Input from '../Input'; +import { InputProps } from '../input.types'; + +const setup = (props: InputProps = {}) => { + return 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..eeb40817 --- /dev/null +++ b/components/input/src/index.ts @@ -0,0 +1,2 @@ +export { type InputProps } 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..ec5552ea --- /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 InputProps 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"] +} From dc27ebd18107a900bfab1b0b7451b36486738b06 Mon Sep 17 00:00:00 2001 From: Gleb Fomin Date: Wed, 12 Feb 2025 12:41:42 +0500 Subject: [PATCH 2/3] chore(input): minor --- components/input/.npmignore | 1 + components/input/package.json | 3 ++- components/input/src/Input.module.css | 1 - components/input/src/Input.tsx | 4 ++-- .../src/__stories__/Input.stories.module.css | 15 --------------- .../input/src/__stories__/Input.stories.tsx | 10 +++++----- components/input/src/__tests__/Input.tests.tsx | 6 ++---- components/input/src/index.ts | 2 +- components/input/src/input.types.ts | 2 +- 9 files changed, 14 insertions(+), 30 deletions(-) diff --git a/components/input/.npmignore b/components/input/.npmignore index 2b07fde8..5e85453c 100644 --- a/components/input/.npmignore +++ b/components/input/.npmignore @@ -1,3 +1,4 @@ src rollup.config.js tsconfig.json +tsconfig.build.json diff --git a/components/input/package.json b/components/input/package.json index 940a8b9b..ac21c694 100644 --- a/components/input/package.json +++ b/components/input/package.json @@ -30,6 +30,7 @@ "access": "public" }, "peerDependencies": { - "react": ">=17" + "react": ">=17", + "classnames": "^2.3.1" } } diff --git a/components/input/src/Input.module.css b/components/input/src/Input.module.css index b8449d9b..efa56649 100644 --- a/components/input/src/Input.module.css +++ b/components/input/src/Input.module.css @@ -30,7 +30,6 @@ --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 { diff --git a/components/input/src/Input.tsx b/components/input/src/Input.tsx index 9f2265e4..eff35041 100644 --- a/components/input/src/Input.tsx +++ b/components/input/src/Input.tsx @@ -1,9 +1,9 @@ import React, { forwardRef } from 'react'; import cn from 'classnames'; -import { InputProps } from './input.types'; +import { IInputProps } from './input.types'; import styles from './Input.module.css'; -const Input = forwardRef((props, ref) => { +const Input = forwardRef((props, ref) => { const { size = 'l', type = 'text', diff --git a/components/input/src/__stories__/Input.stories.module.css b/components/input/src/__stories__/Input.stories.module.css index 7dc434d5..b5950092 100644 --- a/components/input/src/__stories__/Input.stories.module.css +++ b/components/input/src/__stories__/Input.stories.module.css @@ -6,18 +6,3 @@ flex-direction: column; gap: 16px; } - -.container { - margin: auto; - padding: 20px; - width: 40%; - display: flex; - flex-direction: column; - gap: 20px; -} - -.col { - 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 index 52bac67d..dd052000 100644 --- a/components/input/src/__stories__/Input.stories.tsx +++ b/components/input/src/__stories__/Input.stories.tsx @@ -1,14 +1,14 @@ import React, { ReactNode } from 'react'; import { StoryObj } from '@storybook/react'; import Input from '../Input'; -import { InputProps } from '../input.types'; +import { IInputProps } from '../input.types'; import styles from './Input.stories.module.css'; const TemplateWrapper = ({ children }: { children: ReactNode }) => { return
{children}
; }; -const InputSizeTemplate = (args: InputProps) => ( +const InputSizeTemplate = (args: IInputProps) => ( @@ -17,7 +17,7 @@ const InputSizeTemplate = (args: InputProps) => ( ); -const InputVariantTemplate = (args: InputProps) => ( +const InputVariantTemplate = (args: IInputProps) => ( @@ -25,7 +25,7 @@ const InputVariantTemplate = (args: InputProps) => ( ); -const InputSideComponentsTemplate = (args: InputProps) => ( +const InputSideComponentsTemplate = (args: IInputProps) => ( @@ -33,7 +33,7 @@ const InputSideComponentsTemplate = (args: InputProps) => ( ); -const InputInvalidTemplate = (args: InputProps) => ( +const InputInvalidTemplate = (args: IInputProps) => ( diff --git a/components/input/src/__tests__/Input.tests.tsx b/components/input/src/__tests__/Input.tests.tsx index 7f8ce497..70a48993 100644 --- a/components/input/src/__tests__/Input.tests.tsx +++ b/components/input/src/__tests__/Input.tests.tsx @@ -2,11 +2,9 @@ import React from 'react'; import { render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import Input from '../Input'; -import { InputProps } from '../input.types'; +import { IInputProps } from '../input.types'; -const setup = (props: InputProps = {}) => { - return render(); -}; +const setup = (props: IInputProps = {}) => render(); describe('Input Component', () => { test('renders input element', () => { diff --git a/components/input/src/index.ts b/components/input/src/index.ts index eeb40817..a8a522c4 100644 --- a/components/input/src/index.ts +++ b/components/input/src/index.ts @@ -1,2 +1,2 @@ -export { type InputProps } from './input.types'; +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 index ec5552ea..d6c0290a 100644 --- a/components/input/src/input.types.ts +++ b/components/input/src/input.types.ts @@ -6,7 +6,7 @@ type TInputSize = 's' | 'm' | 'l' | 'xl'; type TInputVariant = 'outline' | 'line' | 'unstyled'; -export interface InputProps extends THTMLInputProps { +export interface IInputProps extends THTMLInputProps { size?: TInputSize; variant?: TInputVariant; className?: string; From 8b22a3def4f69744d9479d9e182df806b66e7ce8 Mon Sep 17 00:00:00 2001 From: Gleb Fomin Date: Wed, 12 Feb 2025 12:47:39 +0500 Subject: [PATCH 3/3] chore(input): added `classnames` in peerDependencies --- package-lock.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 42be0fdb..e7256a54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -99,6 +99,14 @@ "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", @@ -14449,7 +14457,6 @@ }, "node_modules/classnames": { "version": "2.3.1", - "dev": true, "license": "MIT" }, "node_modules/clean-css": {