From a051a917f1c1df033c02758d074e8b50bdb081e2 Mon Sep 17 00:00:00 2001
From: adriancofie <38888889+adriancofie@users.noreply.github.com>
Date: Wed, 29 Apr 2026 10:20:16 -0400
Subject: [PATCH] (#27) Add TextInput component
- Wraps native with USWDS usa-input class
- Supports text, email, password, tel, url, number, and search types
- Forwards standard input props for controlled/uncontrolled use
- Includes unit tests, jest-axe accessibility test, and Storybook stories
Closes #27
---
.changeset/textinput-component.md | 5 +
.../ncids/TextInput/TextInput.stories.tsx | 80 +++++++++++++
.../ncids/TextInput/TextInput.test.tsx | 110 ++++++++++++++++++
src/components/ncids/TextInput/TextInput.tsx | 38 ++++++
src/components/ncids/TextInput/index.ts | 2 +
src/components/ncids/index.ts | 2 +
6 files changed, 237 insertions(+)
create mode 100644 .changeset/textinput-component.md
create mode 100644 src/components/ncids/TextInput/TextInput.stories.tsx
create mode 100644 src/components/ncids/TextInput/TextInput.test.tsx
create mode 100644 src/components/ncids/TextInput/TextInput.tsx
create mode 100644 src/components/ncids/TextInput/index.ts
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';