diff --git a/.changeset/breezy-swans-brush.md b/.changeset/breezy-swans-brush.md new file mode 100644 index 00000000..0bfab4da --- /dev/null +++ b/.changeset/breezy-swans-brush.md @@ -0,0 +1,5 @@ +--- +"@devup-ui/components": patch +--- + +Add Input component diff --git a/.changeset/config.json b/.changeset/config.json index 14fd38ad..50fc841a 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -10,7 +10,6 @@ "ignore": [ "*-example", "*-benchmark", - "landing", - "@devup-ui/components" + "landing" ] } \ No newline at end of file diff --git a/.changeset/stupid-brooms-see.md b/.changeset/stupid-brooms-see.md new file mode 100644 index 00000000..f91296dd --- /dev/null +++ b/.changeset/stupid-brooms-see.md @@ -0,0 +1,5 @@ +--- +"@devup-ui/components": patch +--- + +Fix Input comp test diff --git a/packages/components/src/__tests__/index.browser.test.ts b/packages/components/src/__tests__/index.browser.test.ts index 7c1a48da..e19fbb2f 100644 --- a/packages/components/src/__tests__/index.browser.test.ts +++ b/packages/components/src/__tests__/index.browser.test.ts @@ -3,6 +3,7 @@ describe('export', () => { const index = await import('../index') expect({ ...index }).toEqual({ Button: expect.any(Function), + Input: expect.any(Function), }) }) }) diff --git a/packages/components/src/components/Input/Controlled.tsx b/packages/components/src/components/Input/Controlled.tsx new file mode 100644 index 00000000..bbeae7ad --- /dev/null +++ b/packages/components/src/components/Input/Controlled.tsx @@ -0,0 +1,9 @@ +import { useState } from 'react' + +import { Input } from '.' + +export function Controlled() { + const [value, setValue] = useState('') + + return setValue(e.target.value)} value={value} /> +} diff --git a/packages/components/src/components/Input/GlassIcon.tsx b/packages/components/src/components/Input/GlassIcon.tsx new file mode 100644 index 00000000..4f660af5 --- /dev/null +++ b/packages/components/src/components/Input/GlassIcon.tsx @@ -0,0 +1,21 @@ +import { ComponentProps } from 'react' + +export function GlassIcon(props: ComponentProps<'svg'>) { + return ( + + + + ) +} diff --git a/packages/components/src/components/Input/Input.stories.tsx b/packages/components/src/components/Input/Input.stories.tsx new file mode 100644 index 00000000..f8135b6b --- /dev/null +++ b/packages/components/src/components/Input/Input.stories.tsx @@ -0,0 +1,58 @@ +import { Meta, StoryObj } from '@storybook/react-vite' + +import { Controlled } from './Controlled' +import { GlassIcon } from './GlassIcon' +import { Input } from './index' + +type Story = StoryObj + +// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export +const meta: Meta = { + title: 'Devfive/Input', + component: Input, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} + +export const Default: Story = { + args: { + placeholder: 'Input text', + }, +} + +export const ControlledInput: Story = { + args: { + placeholder: 'Input text', + }, + render: () => , +} + +export const Error: Story = { + args: { + placeholder: 'Input text', + error: true, + errorMessage: 'Error message', + }, +} + +export const Disabled: Story = { + args: { + placeholder: 'Input text', + disabled: true, + }, +} + +export const WithIcon: Story = { + args: { + placeholder: 'Input text', + allowClear: true, + icon: , + }, +} + +export default meta diff --git a/packages/components/src/components/Input/__tests__/__snapshots__/index.browser.test.tsx.snap b/packages/components/src/components/Input/__tests__/__snapshots__/index.browser.test.tsx.snap new file mode 100644 index 00000000..d81cafbd --- /dev/null +++ b/packages/components/src/components/Input/__tests__/__snapshots__/index.browser.test.tsx.snap @@ -0,0 +1,247 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Controlled Input > should render with value 1`] = ` +
+
+ +
+
+`; + +exports[`Input > should have typography when typography is provided 1`] = ` +
+
+ +
+
+`; + +exports[`Input > should not have padding right when allowClear is false 1`] = ` +
+
+ +
+
+`; + +exports[`Input > should not show clear button when value is empty 1`] = ` +
+
+ +
+
+`; + +exports[`Input > should pass className prop to error message component 1`] = ` +
+
+ + + Error message + +
+
+`; + +exports[`Input > should pass className prop to icon component 1`] = ` +
+
+
+ + + +
+ +
+
+`; + +exports[`Input > should pass props to ClearButton component 1`] = ` +
+ +
+`; + +exports[`Input > should render disabled icon style when disabled is true 1`] = ` +
+
+
+ + + +
+ +
+
+`; + +exports[`Input > should render error style when error is true 1`] = ` +
+
+ +
+
+`; + +exports[`Input > should render with allowClear prop 1`] = ` +
+
+ +
+
+`; + +exports[`Input > should render with default props 1`] = ` +
+
+ +
+
+`; + +exports[`Input > should render with disabled prop 1`] = ` +
+
+ +
+
+`; + +exports[`Input > should show clear button when value is not empty 1`] = ` +
+
+ +
+
+`; diff --git a/packages/components/src/components/Input/__tests__/index.browser.test.tsx b/packages/components/src/components/Input/__tests__/index.browser.test.tsx new file mode 100644 index 00000000..87da4497 --- /dev/null +++ b/packages/components/src/components/Input/__tests__/index.browser.test.tsx @@ -0,0 +1,173 @@ +import { fireEvent, render } from '@testing-library/react' +import { DevupThemeTypography } from 'node_modules/@devup-ui/react/dist/types/typography' + +import { ClearButton, Input } from '..' +import { Controlled } from '../Controlled' +import { GlassIcon } from '../GlassIcon' + +describe('Input', () => { + it('should render with default props', () => { + const { container } = render() + expect(container).toMatchSnapshot() + }) + + it('should render with disabled prop', () => { + const { container } = render() + expect(container).toMatchSnapshot() + }) + + it('should render with allowClear prop', () => { + const { container } = render() + expect(container).toMatchSnapshot() + expect(container.querySelector('[aria-label="input"]')).toHaveClass( + 'padding-right-0-36px--1', + ) + }) + + it('should not have padding right when allowClear is false', () => { + const { container } = render() + expect(container).toMatchSnapshot() + expect(container.querySelector('[aria-label="input"]')).not.toHaveClass( + 'padding-right-0-36px--1', + ) + }) + + it('should show clear button when value is not empty', () => { + const { container } = render() + expect(container).toMatchSnapshot() + fireEvent.change(container.querySelector('input')!, { + target: { value: 'test' }, + }) + expect(container.querySelector('button')).toBeInTheDocument() + }) + + it('should not show clear button when value is empty', () => { + const { container } = render() + expect(container).toMatchSnapshot() + }) + + it('should be able to clear value by clicking clear button', () => { + const { container } = render() + fireEvent.change(container.querySelector('input')!, { + target: { value: 'test' }, + }) + expect(container.querySelector('button')).toBeInTheDocument() + fireEvent.click(container.querySelector('button')!) + expect(container.querySelector('input')!.value).toBe('') + }) + + it('should be able to render with icon', () => { + const { container } = render( + } />, + ) + expect(container.querySelector('[data-testid="icon"]')).toBeInTheDocument() + }) + + it('should render error style when error is true', () => { + const { container } = render() + expect(container).toMatchSnapshot() + expect(container.querySelector('[aria-label="input"]')).toHaveClass( + 'border-color-0-var(--error,light-dark(#D52B2E,#FF5B5E))--1', + ) + }) + + it('should be able to render with error message', () => { + const { container } = render() + expect( + container.querySelector('[aria-label="error-message"]'), + ).toBeInTheDocument() + }) + + it('should pass colors prop', () => { + const { container } = render( + , + ) + const input = container.querySelector('[aria-label="input"]') + expect(input).toHaveStyle({ + '--primary': 'red', + '--error': 'blue', + '--text': 'green', + }) + }) + + it('should have typography when typography is provided', () => { + const { container } = render( + , + ) + expect(container).toMatchSnapshot() + expect(container.querySelector('input')).toHaveClass('typo-inlineLabelS') + }) + + it('should pass className prop to error message component', () => { + const { container } = render( + , + ) + expect(container).toMatchSnapshot() + expect(container.querySelector('[aria-label="error-message"]')).toHaveClass( + 'error-message', + ) + }) + + it('should pass className prop to icon component', () => { + const { container } = render( + } + />, + ) + expect(container).toMatchSnapshot() + expect(container.querySelector('[aria-label="icon"]')).toHaveClass('icon') + }) + + it('should pass props to ClearButton component', async () => { + const { container } = render() + expect(container).toMatchSnapshot() + const clearButton = container.querySelector('[aria-label="clear-button"]') + expect(clearButton).toBeInTheDocument() + }) + + it('should render disabled icon style when disabled is true', () => { + const { container } = render(} />) + expect(container).toMatchSnapshot() + expect(container.querySelector('[aria-label="icon"]')).toHaveClass( + 'color-0-var(--inputDisabledText,light-dark(#D6D7DE,#373737))--1', + ) + }) + + it('should call onChange prop when it is provided andvalue is changed', () => { + const onChange = vi.fn() + const { container } = render() + fireEvent.change(container.querySelector('input')!, { + target: { value: 'test' }, + }) + expect(onChange).toHaveBeenCalledWith(expect.any(Object)) + }) +}) + +describe('Controlled Input', () => { + it('should render with value', () => { + const { container } = render() + expect(container).toMatchSnapshot() + }) + + it('should update value when it is changed', () => { + const { container } = render() + fireEvent.change(container.querySelector('input')!, { + target: { value: 'test' }, + }) + expect(container.querySelector('input')!.value).toBe('test') + }) +}) diff --git a/packages/components/src/components/Input/index.tsx b/packages/components/src/components/Input/index.tsx new file mode 100644 index 00000000..83a59c53 --- /dev/null +++ b/packages/components/src/components/Input/index.tsx @@ -0,0 +1,208 @@ +'use client' + +import { + Box, + Button, + Center, + DevupThemeTypography, + Input as DevupInput, + Text, +} from '@devup-ui/react' +import { ComponentProps, useState } from 'react' + +interface InputProps + extends Omit, 'className' | 'type'> { + type?: Exclude['type'], 'file'> + typography?: keyof DevupThemeTypography + error?: boolean + errorMessage?: string + allowClear?: boolean + classNames?: { + input?: string + icon?: string + errorMessage?: string + } + colors?: { + primary?: string + error?: string + text?: string + base?: string + iconBold?: string + border?: string + inputBackground?: string + primaryFocus?: string + negative20?: string + } + icon?: React.ReactNode +} + +export function Input({ + defaultValue, + value: valueProp, + onChange: onChangeProp, + typography, + error = false, + errorMessage, + allowClear = true, + icon, + colors, + disabled, + classNames, + ref, + ...props +}: InputProps) { + const [value, setValue] = useState(defaultValue || '') + const handleChange = (e: React.ChangeEvent) => { + setValue(e.target.value) + onChangeProp?.(e) + } + const handleClear = () => { + setValue('') + } + const clearButtonVisible = value && !disabled && allowClear + + return ( + *': { boxSizing: 'border-box' } }} + > + {icon && ( +
+ {icon} +
+ )} + + {clearButtonVisible && } + {errorMessage && ( + + {errorMessage} + + )} +
+ ) +} + +export function ClearButton(props: ComponentProps<'button'>) { + return ( + + ) +} diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index e58450b5..819f4919 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -1 +1,2 @@ export { Button } from './components/Button' +export { Input } from './components/Input'