Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/textinput-component.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@nciocpl/react-components': minor
---

Add `TextInput` (NCIDS Text Input) component. Wraps a native `<input>` 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.
80 changes: 80 additions & 0 deletions src/components/ncids/TextInput/TextInput.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test';

import { TextInput } from './TextInput';

const meta: Meta<typeof TextInput> = {
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 <input>',
},
disabled: { control: 'boolean' },
required: { control: 'boolean' },
placeholder: { control: 'text' },
},
};

export default meta;
type Story = StoryObj<typeof TextInput>;

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(),
},
};
110 changes: 110 additions & 0 deletions src/components/ncids/TextInput/TextInput.test.tsx
Original file line number Diff line number Diff line change
@@ -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('<TextInput />', () => {
afterEach(() => {
cleanup();
});

it('should render an input with usa-input class', () => {
const { container } = render(
<TextInput id="test" name="test" aria-label="Test" />
);
const input = container.querySelector('input');
expect(input).toHaveClass('usa-input');
});

it('should default type to text', () => {
const { container } = render(
<TextInput id="test" name="test" aria-label="Test" />
);
expect(container.querySelector('input')).toHaveAttribute('type', 'text');
});

it.each(['text', 'email', 'password', 'tel', 'url', 'number', 'search'])(
'should render type=%s',
(type) => {
const { container } = render(
<TextInput
id="test"
name="test"
aria-label="Test"
type={type as 'text'}
/>
);
expect(container.querySelector('input')).toHaveAttribute('type', type);
}
);

it('should merge additional className', () => {
const { container } = render(
<TextInput
id="test"
name="test"
aria-label="Test"
className="usa-input--error"
/>
);
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(
<TextInput id="my-id" name="my-name" aria-label="Test" />
);
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(
<TextInput
id="test"
name="test"
aria-label="Test"
onChange={handleChange}
/>
);

await user.type(screen.getByRole('textbox'), 'hello');
expect(handleChange).toHaveBeenCalled();
});

it('should forward standard input props', () => {
const { container } = render(
<TextInput
id="test"
name="test"
aria-label="Test"
placeholder="Enter text"
maxLength={50}
required
disabled
/>
);
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(
<TextInput id="test" name="test" aria-label="Test input" />
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
38 changes: 38 additions & 0 deletions src/components/ncids/TextInput/TextInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import React from 'react';

export type TextInputType =
| 'text'
| 'email'
| 'password'
| 'tel'
| 'url'
| 'number'
| 'search';

export interface TextInputProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
/** Input ID */
id: string;
/** Input name */
name: string;
/** HTML input type */
type?: TextInputType;
/** Additional CSS classes on the <input>. */
className?: string;
}

export const TextInput: React.FC<TextInputProps> = ({
id,
name,
type = 'text',
className,
...rest
}) => {
const classes = ['usa-input', className || ''].filter(Boolean).join(' ');

return (
<input className={classes} id={id} name={name} type={type} {...rest} />
);
};

export default TextInput;
2 changes: 2 additions & 0 deletions src/components/ncids/TextInput/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { TextInput, default } from './TextInput';
export type { TextInputProps, TextInputType } from './TextInput';
2 changes: 2 additions & 0 deletions src/components/ncids/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading