diff --git a/.changeset/textinput-component.md b/.changeset/textinput-component.md new file mode 100644 index 0000000..8440043 --- /dev/null +++ b/.changeset/textinput-component.md @@ -0,0 +1,5 @@ +--- +'@nciocpl/react-components': minor +--- + +Add `TextInput` (NCIDS Text Input) component. Wraps a native `` with USWDS `usa-input` styling and supports text, email, password, tel, url, number, and search input types. Forwards standard input props for use as a controlled or uncontrolled input. diff --git a/src/components/ncids/TextInput/TextInput.stories.tsx b/src/components/ncids/TextInput/TextInput.stories.tsx new file mode 100644 index 0000000..b8f5ec7 --- /dev/null +++ b/src/components/ncids/TextInput/TextInput.stories.tsx @@ -0,0 +1,80 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; + +import { TextInput } from './TextInput'; + +const meta: Meta = { + title: 'NCIDS/TextInput', + component: TextInput, + tags: ['autodocs'], + argTypes: { + type: { + control: 'select', + options: ['text', 'email', 'password', 'tel', 'url', 'number', 'search'], + description: 'HTML input type', + }, + className: { + control: 'text', + description: 'Additional CSS classes on the ', + }, + disabled: { control: 'boolean' }, + required: { control: 'boolean' }, + placeholder: { control: 'text' }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + id: 'default-text-input', + name: 'default-text-input', + type: 'text', + 'aria-label': 'Text input', + onChange: fn(), + }, +}; + +export const Email: Story = { + args: { + id: 'email-input', + name: 'email-input', + type: 'email', + placeholder: 'name@example.com', + 'aria-label': 'Email', + onChange: fn(), + }, +}; + +export const Password: Story = { + args: { + id: 'password-input', + name: 'password-input', + type: 'password', + 'aria-label': 'Password', + onChange: fn(), + }, +}; + +export const Search: Story = { + args: { + id: 'search-input', + name: 'search-input', + type: 'search', + placeholder: 'Search', + 'aria-label': 'Search', + onChange: fn(), + }, +}; + +export const Disabled: Story = { + args: { + id: 'disabled-input', + name: 'disabled-input', + type: 'text', + disabled: true, + 'aria-label': 'Disabled input', + onChange: fn(), + }, +}; diff --git a/src/components/ncids/TextInput/TextInput.test.tsx b/src/components/ncids/TextInput/TextInput.test.tsx new file mode 100644 index 0000000..bb212ca --- /dev/null +++ b/src/components/ncids/TextInput/TextInput.test.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import { cleanup, render, screen } from '@testing-library/react'; +import { axe } from 'vitest-axe'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; + +import { TextInput } from './TextInput'; + +describe('', () => { + afterEach(() => { + cleanup(); + }); + + it('should render an input with usa-input class', () => { + const { container } = render( + + ); + const input = container.querySelector('input'); + expect(input).toHaveClass('usa-input'); + }); + + it('should default type to text', () => { + const { container } = render( + + ); + expect(container.querySelector('input')).toHaveAttribute('type', 'text'); + }); + + it.each(['text', 'email', 'password', 'tel', 'url', 'number', 'search'])( + 'should render type=%s', + (type) => { + const { container } = render( + + ); + expect(container.querySelector('input')).toHaveAttribute('type', type); + } + ); + + it('should merge additional className', () => { + const { container } = render( + + ); + const input = container.querySelector('input'); + expect(input).toHaveClass('usa-input'); + expect(input).toHaveClass('usa-input--error'); + }); + + it('should forward id and name', () => { + const { container } = render( + + ); + const input = container.querySelector('input'); + expect(input).toHaveAttribute('id', 'my-id'); + expect(input).toHaveAttribute('name', 'my-name'); + }); + + it('should call onChange when user types', async () => { + const user = userEvent.setup(); + const handleChange = vi.fn(); + + render( + + ); + + await user.type(screen.getByRole('textbox'), 'hello'); + expect(handleChange).toHaveBeenCalled(); + }); + + it('should forward standard input props', () => { + const { container } = render( + + ); + const input = container.querySelector('input'); + expect(input).toHaveAttribute('placeholder', 'Enter text'); + expect(input).toHaveAttribute('maxLength', '50'); + expect(input).toBeRequired(); + expect(input).toBeDisabled(); + }); + + it('should have no accessibility violations', async () => { + const { container } = render( + + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); +}); diff --git a/src/components/ncids/TextInput/TextInput.tsx b/src/components/ncids/TextInput/TextInput.tsx new file mode 100644 index 0000000..2f2b134 --- /dev/null +++ b/src/components/ncids/TextInput/TextInput.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +export type TextInputType = + | 'text' + | 'email' + | 'password' + | 'tel' + | 'url' + | 'number' + | 'search'; + +export interface TextInputProps + extends Omit, 'type'> { + /** Input ID */ + id: string; + /** Input name */ + name: string; + /** HTML input type */ + type?: TextInputType; + /** Additional CSS classes on the . */ + className?: string; +} + +export const TextInput: React.FC = ({ + id, + name, + type = 'text', + className, + ...rest +}) => { + const classes = ['usa-input', className || ''].filter(Boolean).join(' '); + + return ( + + ); +}; + +export default TextInput; diff --git a/src/components/ncids/TextInput/index.ts b/src/components/ncids/TextInput/index.ts new file mode 100644 index 0000000..a9f7c87 --- /dev/null +++ b/src/components/ncids/TextInput/index.ts @@ -0,0 +1,2 @@ +export { TextInput, default } from './TextInput'; +export type { TextInputProps, TextInputType } from './TextInput'; diff --git a/src/components/ncids/index.ts b/src/components/ncids/index.ts index 130d5d3..4652c93 100644 --- a/src/components/ncids/index.ts +++ b/src/components/ncids/index.ts @@ -5,3 +5,5 @@ export { Collection, CollectionItem } from './Collection'; export type { CollectionProps, CollectionItemProps } from './Collection'; export { Dropdown } from './Dropdown'; export type { DropdownProps } from './Dropdown'; +export { TextInput } from './TextInput'; +export type { TextInputProps, TextInputType } from './TextInput';