From 6eabb5be59c179ee583f72461be05f25cd0606bc 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 1/3] (#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'; From d9e921b369b8cb5bb3782493342f61e7e70e7f46 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 14:55:55 +0000 Subject: [PATCH 2/3] (#21) Implement Autocomplete component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement ARIA combobox pattern (role, aria-expanded, aria-controls, aria-activedescendant, aria-haspopup, aria-labelledby) - Add synchronous options filtering via `options` prop - Add async data loading via `loadOptions` prop - Add debounced input (configurable via `debounceDelay`) - Add custom option rendering via `renderOption` prop - Add custom label/value extraction via `getOptionLabel`/`getOptionValue` - Add keyboard navigation (ArrowUp/Down, Enter, Escape, Tab) - Add clear button (hidden when disabled or empty) - Add loading state indicator - Add configurable no-results message - Add Autocomplete.module.scss CSS module styling - Add comprehensive unit tests with 90%+ coverage - Add jest-axe accessibility test - Add Storybook stories with 6 story variants - Export from ncids/index.ts barrel - Use useCallback for resolveLabel/resolveValue to fix deps array - Add resolveLabel to controlled value sync effect deps - Change activedescendant → activeDescendant (camelCase) - Remove redundant named export (use export function declaration) - Extract stateful Storybook stories into proper components - Fix ArrowUp: allow de-highlighting from index 0 (→ -1) - Use literal ellipsis character instead of unicode escape - Add test for ArrowUp clearing highlight at first item * Adds styles.d.ts for Autocomplete.scs file * Updates Autocomplete.tsx to use MouseEvent instead of PointerEvent * Adjusts Autocomplete.module.scss styling * Adjusts Autocomplete.test to pass lint tests Closes #21 Initial plan chore: initial plan for Autocomplete component Agent-Logs-Url: https://github.com/NCIOCPL/react-app-shared/sessions/20befcde-a76b-49a2-8a61-fe0075b5fcc5 Co-authored-by: adriancofie <38888889+adriancofie@users.noreply.github.com> feat: implement Autocomplete combobox component - Add Autocomplete.tsx with generic TypeScript interface - Implement ARIA combobox pattern (role, aria-expanded, aria-controls, aria-activedescendant, aria-haspopup, aria-labelledby) - Add synchronous options filtering via `options` prop - Add async data loading via `loadOptions` prop - Add debounced input (configurable via `debounceDelay`) - Add custom option rendering via `renderOption` prop - Add custom label/value extraction via `getOptionLabel`/`getOptionValue` - Add keyboard navigation (ArrowUp/Down, Enter, Escape, Tab) - Add clear button (hidden when disabled or empty) - Add loading state indicator - Add configurable no-results message - Add Autocomplete.module.scss CSS module styling - Add comprehensive unit tests with 90%+ coverage - Add jest-axe accessibility test - Add Storybook stories with 6 story variants - Export from ncids/index.ts barrel Agent-Logs-Url: https://github.com/NCIOCPL/react-app-shared/sessions/20befcde-a76b-49a2-8a61-fe0075b5fcc5 Co-authored-by: adriancofie <38888889+adriancofie@users.noreply.github.com> refactor: address code review feedback for Autocomplete component - Use useCallback for resolveLabel/resolveValue to fix deps array - Add resolveLabel to controlled value sync effect deps - Change activedescendant → activeDescendant (camelCase) - Remove redundant named export (use export function declaration) - Extract stateful Storybook stories into proper components - Fix ArrowUp: allow de-highlighting from index 0 (→ -1) - Use literal ellipsis character instead of unicode escape - Add test for ArrowUp clearing highlight at first item Agent-Logs-Url: https://github.com/NCIOCPL/react-app-shared/sessions/20befcde-a76b-49a2-8a61-fe0075b5fcc5 Co-authored-by: adriancofie <38888889+adriancofie@users.noreply.github.com> fix: remove redundant optional chaining after bounds check in Autocomplete Agent-Logs-Url: https://github.com/NCIOCPL/react-app-shared/sessions/20befcde-a76b-49a2-8a61-fe0075b5fcc5 Co-authored-by: adriancofie <38888889+adriancofie@users.noreply.github.com> (#73) Implement Autocomplete component * Adds styles.d.ts for Autocomplete.scs file * Updates Autocomplete.tsx to use MouseEvent instead of PointerEvent * Adjusts Autocomplete.module.scss styling * Adjusts Autocomplete.test to pass lint tests Closes #73 --- .npmrc | 2 +- .../Autocomplete/Autocomplete.module.scss | 78 ++ .../Autocomplete/Autocomplete.stories.tsx | 190 +++++ .../ncids/Autocomplete/Autocomplete.test.tsx | 691 ++++++++++++++++++ .../ncids/Autocomplete/Autocomplete.tsx | 398 ++++++++++ src/components/ncids/Autocomplete/index.ts | 2 + src/components/ncids/index.ts | 2 + src/styles.d.ts | 6 + 8 files changed, 1368 insertions(+), 1 deletion(-) create mode 100644 src/components/ncids/Autocomplete/Autocomplete.module.scss create mode 100644 src/components/ncids/Autocomplete/Autocomplete.stories.tsx create mode 100644 src/components/ncids/Autocomplete/Autocomplete.test.tsx create mode 100644 src/components/ncids/Autocomplete/Autocomplete.tsx create mode 100644 src/components/ncids/Autocomplete/index.ts create mode 100644 src/styles.d.ts diff --git a/.npmrc b/.npmrc index 5b3a720..f41de31 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +1,3 @@ @nciocpl:registry=https://npm.pkg.github.com engine-strict=true -legacy-peer-deps=true \ No newline at end of file +legacy-peer-deps=true diff --git a/src/components/ncids/Autocomplete/Autocomplete.module.scss b/src/components/ncids/Autocomplete/Autocomplete.module.scss new file mode 100644 index 0000000..3708e6e --- /dev/null +++ b/src/components/ncids/Autocomplete/Autocomplete.module.scss @@ -0,0 +1,78 @@ +.autocomplete { + position: relative; + display: inline-block; +} + +.inputWrapper { + position: relative; + display: flex; + align-items: center; +} + +.autocompleteInput { + width: 100%; + padding-right: 2rem; +} + +.clearButton { + position: absolute; + right: 0.5rem; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + cursor: pointer; + font-size: 1.25rem; + line-height: 1; + padding: 0 0.25rem; + color: #71767a; + + &:hover { + color: #1b1b1b; + } + + &:focus { + outline: 0.25rem solid #2491ff; + outline-offset: 0; + } +} + +.listbox { + position: absolute; + z-index: 100; + top: 100%; + left: 0; + right: 0; + background-color: #ffffff; + border: 1px solid #a9aeb1; + border-top: none; + border-radius: 0 0 0.25rem 0.25rem; + list-style: none; + margin: 0; + padding: 0; + max-height: 15rem; + overflow-y: auto; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +.option { + padding: 0.5rem 1rem; + cursor: pointer; + font-size: 1rem; + + &:hover { + background-color: #f0f0f0; + } +} + +.optionHighlighted { + background-color: #d9e8f6; + outline: none; +} + +.statusMessage { + padding: 0.5rem 1rem; + color: #71767a; + font-style: italic; + cursor: default; +} diff --git a/src/components/ncids/Autocomplete/Autocomplete.stories.tsx b/src/components/ncids/Autocomplete/Autocomplete.stories.tsx new file mode 100644 index 0000000..9ef720b --- /dev/null +++ b/src/components/ncids/Autocomplete/Autocomplete.stories.tsx @@ -0,0 +1,190 @@ +import React, { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; + +import { Autocomplete } from './Autocomplete'; +import type { AutocompleteOption } from './Autocomplete'; + +const fruits: AutocompleteOption[] = [ + { label: 'Apple', value: 'apple' }, + { label: 'Apricot', value: 'apricot' }, + { label: 'Avocado', value: 'avocado' }, + { label: 'Banana', value: 'banana' }, + { label: 'Blueberry', value: 'blueberry' }, + { label: 'Cherry', value: 'cherry' }, + { label: 'Coconut', value: 'coconut' }, + { label: 'Grape', value: 'grape' }, + { label: 'Kiwi', value: 'kiwi' }, + { label: 'Lemon', value: 'lemon' }, + { label: 'Mango', value: 'mango' }, + { label: 'Orange', value: 'orange' }, + { label: 'Peach', value: 'peach' }, + { label: 'Pear', value: 'pear' }, + { label: 'Pineapple', value: 'pineapple' }, + { label: 'Strawberry', value: 'strawberry' }, + { label: 'Watermelon', value: 'watermelon' }, +]; + +const meta: Meta> = { + title: 'NCIDS/Autocomplete', + component: Autocomplete, + tags: ['autodocs'], + argTypes: { + debounceDelay: { + control: { type: 'number', min: 0, max: 1000, step: 50 }, + description: 'Debounce delay in milliseconds', + }, + disabled: { control: 'boolean' }, + noOptionsMessage: { control: 'text' }, + loadingMessage: { control: 'text' }, + placeholder: { control: 'text' }, + }, +}; + +export default meta; +type Story = StoryObj>; + +/** Basic usage with a synchronous list of options. */ +export const Default: Story = { + args: { + id: 'fruit-autocomplete', + label: 'Fruit', + options: fruits, + placeholder: 'Type to search…', + onChange: fn(), + }, +}; + +/** Asynchronous data loading with a simulated 400 ms network delay. */ +export const AsyncLoadOptions: Story = { + args: { + id: 'fruit-async', + label: 'Fruit (async)', + placeholder: 'Type to search…', + debounceDelay: 300, + loadOptions: (query: string) => + new Promise((resolve) => + setTimeout( + () => + resolve( + fruits.filter((f) => + f.label.toLowerCase().includes(query.toLowerCase()) + ) + ), + 400 + ) + ), + onChange: fn(), + }, +}; + +/** Customise the message when no option matches the search query. */ +export const CustomNoOptionsMessage: Story = { + args: { + id: 'fruit-no-opts', + label: 'Fruit', + options: fruits, + placeholder: 'Try "xyz"…', + noOptionsMessage: 'No matching fruit — try something else.', + onChange: fn(), + }, +}; + +/** Custom option renderer that adds an emoji prefix. */ +export const CustomRenderOption: Story = { + args: { + id: 'fruit-custom', + label: 'Fruit', + options: fruits, + renderOption: (opt: AutocompleteOption, isHighlighted: boolean) => ( + + 🍓 {opt.label} + + ), + onChange: fn(), + }, +}; + +/** Disabled state. */ +export const Disabled: Story = { + args: { + id: 'fruit-disabled', + label: 'Fruit', + options: fruits, + value: { label: 'Apple', value: 'apple' }, + disabled: true, + onChange: fn(), + }, +}; + +/** Controlled component — the parent manages the selected value. */ +const ControlledTemplate = (args: React.ComponentProps>) => { + const [value, setValue] = useState(null); + return ( +
+ setValue(opt)} + /> +

+ Selected:{' '} + {value ? `${value.label} (${value.value})` : 'none'} +

+
+ ); +}; + +export const Controlled: Story = { + render: (args) => , + args: { + id: 'fruit-controlled', + label: 'Fruit', + options: fruits, + placeholder: 'Pick a fruit…', + }, +}; + +interface Country { + name: string; + code: string; +} + +const countries: Country[] = [ + { name: 'United States', code: 'us' }, + { name: 'United Kingdom', code: 'uk' }, + { name: 'Canada', code: 'ca' }, + { name: 'Australia', code: 'au' }, + { name: 'Germany', code: 'de' }, + { name: 'France', code: 'fr' }, + { name: 'Japan', code: 'jp' }, +]; + +const CustomOptionShapeTemplate = () => { + const [value, setValue] = useState(null); + return ( +
+ + id="country" + label="Country" + options={countries} + getOptionLabel={(c) => c.name} + getOptionValue={(c) => c.code} + placeholder="Search countries…" + value={value} + onChange={(c) => setValue(c)} + /> + {value && ( +

+ Selected: {value.name} ({value.code}) +

+ )} +
+ ); +}; + +/** Using generic options with custom getOptionLabel / getOptionValue. */ +export const CustomOptionShape: Story = { + render: () => , + args: {}, +}; diff --git a/src/components/ncids/Autocomplete/Autocomplete.test.tsx b/src/components/ncids/Autocomplete/Autocomplete.test.tsx new file mode 100644 index 0000000..75e9ceb --- /dev/null +++ b/src/components/ncids/Autocomplete/Autocomplete.test.tsx @@ -0,0 +1,691 @@ +import React from 'react'; +import { + cleanup, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import { axe } from 'vitest-axe'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, + beforeAll, +} from 'vitest'; +import userEvent from '@testing-library/user-event'; + +import { Autocomplete } from './Autocomplete'; +import type { AutocompleteOption } from './Autocomplete'; + +const fruits: AutocompleteOption[] = [ + { label: 'Apple', value: 'apple' }, + { label: 'Apricot', value: 'apricot' }, + { label: 'Banana', value: 'banana' }, + { label: 'Blueberry', value: 'blueberry' }, + { label: 'Cherry', value: 'cherry' }, +]; + +const loadFruits = vi.fn( + (query: string): Promise => + Promise.resolve( + fruits.filter((f) => f.label.toLowerCase().includes(query.toLowerCase())) + ) +); + +describe('', () => { + beforeAll(() => { + // https://vitest.dev/api/vi.html#vi-stubglobal + vi.stubGlobal('jest', { + advanceTimersByTime: vi.advanceTimersByTime.bind(vi), + }); + }); + + beforeEach(() => { + vi.useFakeTimers(); + // localStorage.clear(); + loadFruits.mockClear(); + }); + + afterEach(() => { + vi.useRealTimers(); + cleanup(); + }); + + // ── Rendering ───────────────────────────────────────────────────────────── + + it('renders a labelled combobox input', () => { + render(); + expect(screen.getByRole('combobox', { name: 'Test' })).toBeInTheDocument(); + //expect(screen.getByLabelText('Test')).toBeInTheDocument(); + }); + + it('renders with placeholder', () => { + render( + + ); + expect(screen.getByPlaceholderText('Search fruit…')).toBeInTheDocument(); + }); + + it('applies additional wrapper className', () => { + const { container } = render( + + ); + expect(container.firstChild).toHaveClass('my-custom-class'); + }); + + it('applies additional inputClassName', () => { + render( + + ); + expect(screen.getByRole('combobox')).toHaveClass('my-input-class'); + }); + + it('renders disabled input', () => { + render(); + expect(screen.getByRole('combobox')).toBeDisabled(); + }); + + it('does not show clear button when input is empty', () => { + render(); + expect( + screen.queryByRole('button', { name: 'Clear' }) + ).not.toBeInTheDocument(); + }); + + it('does not show clear button when disabled', async () => { + // const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render(); + // Can't type into disabled input, so just assert no clear button + expect( + screen.queryByRole('button', { name: 'Clear' }) + ).not.toBeInTheDocument(); + }); + + // ── Synchronous options ──────────────────────────────────────────────────── + + it('filters synchronous options as user types', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render(); + + await user.type(screen.getByRole('combobox'), 'ap'); + vi.runAllTimers(); + + await waitFor(() => { + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + + const listbox = screen.getByRole('listbox'); + const options = within(listbox).getAllByRole('option'); + expect(options).toHaveLength(2); + expect(options[0]).toHaveTextContent('Apple'); + expect(options[1]).toHaveTextContent('Apricot'); + }); + + it('shows no-results message when nothing matches', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render( + + ); + + await user.type(screen.getByRole('combobox'), 'zzz'); + vi.runAllTimers(); + + await waitFor(() => { + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + expect(screen.getByText('No fruit found.')).toBeInTheDocument(); + }); + + it('uses default no-results message', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render(); + + await user.type(screen.getByRole('combobox'), 'zzz'); + vi.runAllTimers(); + + await waitFor(() => + expect(screen.getByText('No results found.')).toBeInTheDocument() + ); + }); + + it('closes dropdown when input is cleared by typing', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render(); + + const input = screen.getByRole('combobox'); + await user.type(input, 'ap'); + vi.runAllTimers(); + await waitFor(() => + expect(screen.getByRole('listbox')).toBeInTheDocument() + ); + + await user.clear(input); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + + // ── Async options ────────────────────────────────────────────────────────── + + it('shows loading message while loadOptions resolves', async () => { + let resolve: (v: AutocompleteOption[]) => void; + const slowLoad = vi.fn( + () => + new Promise((res) => { + resolve = res; + }) + ); + + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render( + + ); + + await user.type(screen.getByRole('combobox'), 'ap'); + vi.runAllTimers(); + + await waitFor(() => + expect(screen.getByText('Fetching…')).toBeInTheDocument() + ); + + // Resolve the promise + resolve!([{ label: 'Apple', value: 'apple' }]); + await waitFor(() => + expect(screen.queryByText('Fetching…')).not.toBeInTheDocument() + ); + expect(screen.getByText('Apple')).toBeInTheDocument(); + }); + + it('uses default loading message', async () => { + let resolve: (v: AutocompleteOption[]) => void; + const slowLoad = vi.fn( + () => + new Promise((res) => { + resolve = res; + }) + ); + + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render(); + + await user.type(screen.getByRole('combobox'), 'ap'); + vi.runAllTimers(); + + await waitFor(() => + expect(screen.getByText('Loading…')).toBeInTheDocument() + ); + resolve!([]); + }); + + it('calls loadOptions after debounce delay', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render( + + ); + + await user.type(screen.getByRole('combobox'), 'ban'); + + // Not called yet (timers not advanced) + expect(loadFruits).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(500); + await waitFor(() => expect(loadFruits).toHaveBeenCalledWith('ban')); + }); + + // ── Option selection ─────────────────────────────────────────────────────── + + it('calls onChange with the selected option', async () => { + const handleChange = vi.fn(); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render( + + ); + + await user.type(screen.getByRole('combobox'), 'ban'); + vi.runAllTimers(); + await waitFor(() => + expect(screen.getByRole('listbox')).toBeInTheDocument() + ); + + await user.click(screen.getByText('Banana')); + expect(handleChange).toHaveBeenCalledWith({ + label: 'Banana', + value: 'banana', + }); + expect(screen.getByRole('combobox')).toHaveValue('Banana'); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + + it('marks selected option with aria-selected', async () => { + window.HTMLElement.prototype.scrollIntoView = function () {}; + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render(); + + // Select banana first + await user.type(screen.getByRole('combobox'), 'ban'); + vi.runAllTimers(); + await waitFor(() => + expect(screen.getByRole('listbox')).toBeInTheDocument() + ); + await user.click(screen.getByText('Banana')); + + // Re-open dropdown using ArrowDown (without clearing so selectedValue is preserved) + await user.keyboard('{ArrowDown}'); + vi.runAllTimers(); + await waitFor(() => + expect(screen.getByRole('listbox')).toBeInTheDocument() + ); + + const option = screen.getByRole('option', { name: 'Banana' }); + expect(option).toHaveAttribute('aria-selected', 'true'); + }); + + // ── Clear button ─────────────────────────────────────────────────────────── + + it('shows clear button after typing', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render(); + + await user.type(screen.getByRole('combobox'), 'a'); + expect(screen.getByRole('button', { name: 'Clear' })).toBeInTheDocument(); + }); + + it('clears input and calls onChange(null) when clear is clicked', async () => { + const handleChange = vi.fn(); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render( + + ); + + await user.type(screen.getByRole('combobox'), 'Apple'); + vi.runAllTimers(); + await waitFor(() => + expect(screen.getByRole('listbox')).toBeInTheDocument() + ); + await user.click(screen.getByText('Apple')); + expect(screen.getByRole('combobox')).toHaveValue('Apple'); + + await user.click(screen.getByRole('button', { name: 'Clear' })); + expect(screen.getByRole('combobox')).toHaveValue(''); + expect(handleChange).toHaveBeenLastCalledWith(null); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + + // ── Keyboard navigation ──────────────────────────────────────────────────── + + it('highlights options with ArrowDown', async () => { + window.HTMLElement.prototype.scrollIntoView = function () {}; + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render(); + + await user.type(screen.getByRole('combobox'), 'a'); + vi.runAllTimers(); + await waitFor(() => + expect(screen.getByRole('listbox')).toBeInTheDocument() + ); + + await user.keyboard('{ArrowDown}'); + const options = screen.getAllByRole('option'); + // First option should be highlighted (aria-activedescendant set) + expect(screen.getByRole('combobox')).toHaveAttribute( + 'aria-activedescendant', + options[0].id + ); + }); + + it('highlights options with ArrowUp', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render(); + + await user.type(screen.getByRole('combobox'), 'a'); + vi.runAllTimers(); + await waitFor(() => + expect(screen.getByRole('listbox')).toBeInTheDocument() + ); + + // Go down twice, then up once → back to index 0 + await user.keyboard('{ArrowDown}{ArrowDown}{ArrowUp}'); + const options = screen.getAllByRole('option'); + expect(screen.getByRole('combobox')).toHaveAttribute( + 'aria-activedescendant', + options[0].id + ); + }); + + it('clears highlight when ArrowUp is pressed at the first option', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render(); + + await user.type(screen.getByRole('combobox'), 'a'); + vi.runAllTimers(); + await waitFor(() => + expect(screen.getByRole('listbox')).toBeInTheDocument() + ); + + // ArrowDown → index 0, then ArrowUp → index -1 (no highlight) + await user.keyboard('{ArrowDown}{ArrowUp}'); + expect( + screen.getByRole('combobox').getAttribute('aria-activedescendant') + ).toBeFalsy(); + }); + + it('selects the highlighted option on Enter', async () => { + const handleChange = vi.fn(); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render( + + ); + + await user.type(screen.getByRole('combobox'), 'ap'); + vi.runAllTimers(); + await waitFor(() => + expect(screen.getByRole('listbox')).toBeInTheDocument() + ); + + await user.keyboard('{ArrowDown}{Enter}'); + expect(handleChange).toHaveBeenCalledWith({ + label: 'Apple', + value: 'apple', + }); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + + it('closes the dropdown on Escape', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render(); + + await user.type(screen.getByRole('combobox'), 'a'); + vi.runAllTimers(); + await waitFor(() => + expect(screen.getByRole('listbox')).toBeInTheDocument() + ); + + await user.keyboard('{Escape}'); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + + it('opens dropdown with ArrowDown when input has value but list is closed', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render(); + + const input = screen.getByRole('combobox'); + await user.type(input, 'a'); + vi.runAllTimers(); + await waitFor(() => + expect(screen.getByRole('listbox')).toBeInTheDocument() + ); + + // Close via Escape + await user.keyboard('{Escape}'); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + + // Re-open with ArrowDown + await user.keyboard('{ArrowDown}'); + vi.runAllTimers(); + await waitFor(() => + expect(screen.getByRole('listbox')).toBeInTheDocument() + ); + }); + + // ── Custom rendering ─────────────────────────────────────────────────────── + + it('uses renderOption to render custom option content', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render( + ( + {opt.label} 🍎 + )} + /> + ); + + await user.type(screen.getByRole('combobox'), 'apple'); + vi.runAllTimers(); + await waitFor(() => + expect(screen.getByRole('listbox')).toBeInTheDocument() + ); + + expect(screen.getByTestId('custom-option')).toHaveTextContent('Apple 🍎'); + }); + + // ── Custom getOptionLabel / getOptionValue ───────────────────────────────── + + it('supports custom getOptionLabel and getOptionValue', async () => { + interface Country { + name: string; + code: string; + } + const countries: Country[] = [ + { name: 'United States', code: 'us' }, + { name: 'United Kingdom', code: 'uk' }, + ]; + const handleChange = vi.fn(); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + + render( + c.name} + getOptionValue={(c) => c.code} + onChange={handleChange} + /> + ); + + await user.type(screen.getByRole('combobox'), 'unit'); + vi.runAllTimers(); + await waitFor(() => + expect(screen.getByRole('listbox')).toBeInTheDocument() + ); + + expect(screen.getByText('United States')).toBeInTheDocument(); + expect(screen.getByText('United Kingdom')).toBeInTheDocument(); + + await user.click(screen.getByText('United States')); + expect(handleChange).toHaveBeenCalledWith({ + name: 'United States', + code: 'us', + }); + expect(screen.getByRole('combobox')).toHaveValue('United States'); + }); + + // ── Controlled value ─────────────────────────────────────────────────────── + + it('displays the controlled value label in the input', () => { + render( + + ); + expect(screen.getByRole('combobox')).toHaveValue('Cherry'); + }); + + it('updates the displayed label when the controlled value changes', () => { + const { rerender } = render( + + ); + expect(screen.getByRole('combobox')).toHaveValue('Apple'); + + rerender( + + ); + expect(screen.getByRole('combobox')).toHaveValue('Cherry'); + }); + + it('clears the input when controlled value is set to null', () => { + const { rerender } = render( + + ); + rerender( + + ); + expect(screen.getByRole('combobox')).toHaveValue(''); + }); + + // ── ARIA attributes ──────────────────────────────────────────────────────── + + it('sets aria-expanded=false when dropdown is closed', () => { + render(); + expect(screen.getByRole('combobox')).toHaveAttribute( + 'aria-expanded', + 'false' + ); + }); + + it('sets aria-expanded=true when dropdown is open', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render(); + + await user.type(screen.getByRole('combobox'), 'a'); + vi.runAllTimers(); + await waitFor(() => + expect(screen.getByRole('combobox')).toHaveAttribute( + 'aria-expanded', + 'true' + ) + ); + }); + + it('sets aria-haspopup="listbox" on the input', () => { + render(); + expect(screen.getByRole('combobox')).toHaveAttribute( + 'aria-haspopup', + 'listbox' + ); + }); + + it('sets aria-controls on the input pointing to the listbox id', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render(); + + await user.type(screen.getByRole('combobox'), 'a'); + vi.runAllTimers(); + await waitFor(() => + expect(screen.getByRole('listbox')).toBeInTheDocument() + ); + + const input = screen.getByRole('combobox'); + const listbox = screen.getByRole('listbox'); + expect(input).toHaveAttribute('aria-controls', listbox.id); + }); + + it('sets aria-activedescendant to the highlighted option id', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render(); + + await user.type(screen.getByRole('combobox'), 'a'); + vi.runAllTimers(); + await waitFor(() => + expect(screen.getByRole('listbox')).toBeInTheDocument() + ); + + // No item highlighted initially + expect( + screen.getByRole('combobox').getAttribute('aria-activedescendant') + ).toBeFalsy(); + + await user.keyboard('{ArrowDown}'); + const firstOption = screen.getAllByRole('option')[0]; + expect(screen.getByRole('combobox')).toHaveAttribute( + 'aria-activedescendant', + firstOption.id + ); + }); + + // ── Accessibility ────────────────────────────────────────────────────────── + + it('has no accessibility violations in the default (closed) state', async () => { + vi.useRealTimers(); + const { container } = render( + + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('has no accessibility violations in the open state', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + const { container } = render( + + ); + + await user.type(screen.getByRole('combobox'), 'a'); + vi.runAllTimers(); + await waitFor(() => + expect(screen.getByRole('listbox')).toBeInTheDocument() + ); + + vi.useRealTimers(); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); +}); diff --git a/src/components/ncids/Autocomplete/Autocomplete.tsx b/src/components/ncids/Autocomplete/Autocomplete.tsx new file mode 100644 index 0000000..5791f49 --- /dev/null +++ b/src/components/ncids/Autocomplete/Autocomplete.tsx @@ -0,0 +1,398 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; + +import styles from './Autocomplete.module.scss'; + +export interface AutocompleteProps { + /** Input element ID */ + id: string; + /** Visible label text for the input */ + label: string; + /** Synchronous list of options to filter against the input value */ + options?: T[]; + /** + * Async function called with the current input value to load options. + * Takes precedence over the `options` prop when provided. + */ + loadOptions?: (inputValue: string) => Promise; + /** Debounce delay in milliseconds before calling loadOptions. Defaults to 300. */ + debounceDelay?: number; + /** Custom renderer for each option row */ + renderOption?: (option: T, isHighlighted: boolean) => React.ReactNode; + /** Extract the display label string from an option. Defaults to `option.label`. */ + getOptionLabel?: (option: T) => string; + /** Extract the unique value string from an option. Defaults to `option.value`. */ + getOptionValue?: (option: T) => string; + /** Placeholder text for the input */ + placeholder?: string; + /** Message displayed when no options match the input. Defaults to "No results found." */ + noOptionsMessage?: string; + /** Message displayed while options are loading. Defaults to "Loading…" */ + loadingMessage?: string; + /** Called when the user selects an option or clears the input */ + onChange?: (value: T | null) => void; + /** Controlled currently-selected value */ + value?: T | null; + /** Disable the input */ + disabled?: boolean; + /** Additional CSS class on the wrapper element */ + className?: string; + /** Additional CSS class on the text input */ + inputClassName?: string; +} + +export interface AutocompleteOption { + label: string; + value: string; +} + +const defaultGetLabel = (option: AutocompleteOption) => option.label; +const defaultGetValue = (option: AutocompleteOption) => option.value; + +export function Autocomplete({ + id, + label, + options, + loadOptions, + debounceDelay = 300, + renderOption, + getOptionLabel, + getOptionValue, + placeholder, + noOptionsMessage = 'No results found.', + loadingMessage = 'Loading…', + onChange, + value, + disabled = false, + className, + inputClassName, +}: AutocompleteProps): React.ReactElement { + const resolveLabel = useCallback( + (opt: T) => ((getOptionLabel ?? defaultGetLabel) as (o: T) => string)(opt), + [getOptionLabel] + ); + const resolveValue = useCallback( + (opt: T) => ((getOptionValue ?? defaultGetValue) as (o: T) => string)(opt), + [getOptionValue] + ); + + const [inputValue, setInputValue] = useState( + value != null ? resolveLabel(value) : '' + ); + const [filteredOptions, setFilteredOptions] = useState([]); + const [isOpen, setIsOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [highlightedIndex, setHighlightedIndex] = useState(-1); + const [selectedValue, setSelectedValue] = useState( + value !== undefined ? (value ?? null) : null + ); + + const listboxId = `${id}-listbox`; + const inputRef = useRef(null); + const listboxRef = useRef(null); + const wrapperRef = useRef(null); + const debounceTimerRef = useRef | null>(null); + const isMountedRef = useRef(true); + + // Sync controlled value changes + useEffect(() => { + if (value !== undefined) { + setSelectedValue(value ?? null); + setInputValue(value != null ? resolveLabel(value) : ''); + } + }, [value, resolveLabel]); + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + + const openDropdown = useCallback( + async (query: string) => { + if (loadOptions) { + setIsLoading(true); + setIsOpen(true); + try { + const results = await loadOptions(query); + if (isMountedRef.current) { + setFilteredOptions(results); + } + } finally { + if (isMountedRef.current) { + setIsLoading(false); + } + } + } else if (options) { + const lowerQuery = query.toLowerCase(); + const filtered = options.filter((opt) => + resolveLabel(opt).toLowerCase().includes(lowerQuery) + ); + setFilteredOptions(filtered); + setIsOpen(true); + } + }, + [loadOptions, options, resolveLabel] + ); + + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + const newValue = e.target.value; + setInputValue(newValue); + setHighlightedIndex(-1); + + // Clear selection when user modifies input + if (selectedValue !== null) { + setSelectedValue(null); + onChange?.(null); + } + + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + + if (newValue.trim() === '') { + setIsOpen(false); + setFilteredOptions([]); + return; + } + + debounceTimerRef.current = setTimeout(() => { + openDropdown(newValue); + }, debounceDelay); + }, + [debounceDelay, onChange, openDropdown, selectedValue] + ); + + const selectOption = useCallback( + (option: T) => { + setSelectedValue(option); + setInputValue(resolveLabel(option)); + setIsOpen(false); + setHighlightedIndex(-1); + onChange?.(option); + inputRef.current?.focus(); + }, + [onChange, resolveLabel] + ); + + const handleClear = useCallback(() => { + setInputValue(''); + setSelectedValue(null); + setFilteredOptions([]); + setIsOpen(false); + setHighlightedIndex(-1); + onChange?.(null); + inputRef.current?.focus(); + }, [onChange]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (!isOpen) { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + if (inputValue.trim()) { + openDropdown(inputValue); + } + } + return; + } + + switch (e.key) { + case 'ArrowDown': { + e.preventDefault(); + setHighlightedIndex((prev) => + prev < filteredOptions.length - 1 ? prev + 1 : prev + ); + break; + } + case 'ArrowUp': { + e.preventDefault(); + setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : -1)); + break; + } + case 'Enter': { + e.preventDefault(); + if ( + highlightedIndex >= 0 && + highlightedIndex < filteredOptions.length + ) { + selectOption(filteredOptions[highlightedIndex]); + } + break; + } + case 'Escape': { + e.preventDefault(); + setIsOpen(false); + setHighlightedIndex(-1); + break; + } + case 'Tab': { + setIsOpen(false); + setHighlightedIndex(-1); + break; + } + } + }, + [ + filteredOptions, + highlightedIndex, + inputValue, + isOpen, + openDropdown, + selectOption, + ] + ); + + // Scroll highlighted item into view + useEffect(() => { + if (highlightedIndex < 0 || !listboxRef.current) return; + if (highlightedIndex >= listboxRef.current.children.length) return; + const item = listboxRef.current.children[highlightedIndex] as HTMLElement; + item.scrollIntoView({ block: 'nearest' }); + }, [highlightedIndex]); + + // Close on outside click + useEffect(() => { + const handleMouseDown = (e: MouseEvent) => { + if ( + wrapperRef.current && + !wrapperRef.current.contains(e.target as Node) + ) { + setIsOpen(false); + setHighlightedIndex(-1); + } + }; + document.addEventListener('mousedown', handleMouseDown); + return () => document.removeEventListener('mousedown', handleMouseDown); + }, []); + + // Clean up debounce timer on unmount + useEffect(() => { + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + }; + }, []); + + const optionId = (index: number) => `${id}-option-${index}`; + + const labelId = `${id}-label`; + + const activeDescendant = + isOpen && highlightedIndex >= 0 ? optionId(highlightedIndex) : undefined; + + const wrapperClasses = [styles.autocomplete, className || ''] + .filter(Boolean) + .join(' '); + + const inputClasses = [ + 'usa-input', + styles.autocompleteInput, + inputClassName || '', + ] + .filter(Boolean) + .join(' '); + + return ( +
+ +
+ + {inputValue && !disabled && ( + + )} +
+ + +
+ ); +} + +export default Autocomplete; diff --git a/src/components/ncids/Autocomplete/index.ts b/src/components/ncids/Autocomplete/index.ts new file mode 100644 index 0000000..458a0b2 --- /dev/null +++ b/src/components/ncids/Autocomplete/index.ts @@ -0,0 +1,2 @@ +export { Autocomplete } from './Autocomplete'; +export type { AutocompleteProps, AutocompleteOption } from './Autocomplete'; diff --git a/src/components/ncids/index.ts b/src/components/ncids/index.ts index 4652c93..36e10b4 100644 --- a/src/components/ncids/index.ts +++ b/src/components/ncids/index.ts @@ -7,3 +7,5 @@ export { Dropdown } from './Dropdown'; export type { DropdownProps } from './Dropdown'; export { TextInput } from './TextInput'; export type { TextInputProps, TextInputType } from './TextInput'; +export { Autocomplete } from './Autocomplete'; +export type { AutocompleteProps, AutocompleteOption } from './Autocomplete'; diff --git a/src/styles.d.ts b/src/styles.d.ts new file mode 100644 index 0000000..ef6fe41 --- /dev/null +++ b/src/styles.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.scss' { + const classes: { readonly [key: string]: string }; + export default classes; +} + +declare module '*.scss'; From b6022915190d8711208f9ccc534cc0d705ebc431 Mon Sep 17 00:00:00 2001 From: adriancofie <38888889+adriancofie@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:15:33 -0400 Subject: [PATCH 3/3] (#21) Add Autocomplete search-box features and fix style packaging - Replace the broken CSS-module (Autocomplete.module.scss compiled to `var styles = undefined` and shipped no CSS) with a plain SCSS partial (_autocomplete.scss) forwarded into the bundled stylesheet, using static nci-autocomplete* class names so styles ship in dist/styles/index.css. - Add minChars + minCharsMessage (below-threshold hint). - Add onSubmit + a search button; Enter submits when no option is highlighted. - Add highlightMatch to bold the matched substring, and searchButtonLabel. - Tests (46) and a SearchBox story; add changeset (minor). Closes #21 --- .changeset/autocomplete-component.md | 8 + .../Autocomplete/Autocomplete.module.scss | 78 ------ .../Autocomplete/Autocomplete.stories.tsx | 34 ++- .../ncids/Autocomplete/Autocomplete.test.tsx | 199 ++++++++++++++++ .../ncids/Autocomplete/Autocomplete.tsx | 222 ++++++++++++++---- src/styles/_autocomplete.scss | 130 ++++++++++ src/styles/index.scss | 3 + 7 files changed, 550 insertions(+), 124 deletions(-) create mode 100644 .changeset/autocomplete-component.md delete mode 100644 src/components/ncids/Autocomplete/Autocomplete.module.scss create mode 100644 src/styles/_autocomplete.scss diff --git a/.changeset/autocomplete-component.md b/.changeset/autocomplete-component.md new file mode 100644 index 0000000..2cdf08a --- /dev/null +++ b/.changeset/autocomplete-component.md @@ -0,0 +1,8 @@ +--- +'@nciocpl/react-components': minor +--- + +Add `Autocomplete` (NCIDS combobox) component and fix its packaging. + +- **Search-box features:** new `minChars` / `minCharsMessage` props (shows a "enter N or more characters" hint below the threshold), an `onSubmit` callback plus a built-in search button (Enter submits when no option is highlighted; a highlighted option is selected first), `highlightMatch` to bold the matched substring in each option, and a `searchButtonLabel` for localization. +- **Styling now ships:** the component previously relied on a CSS-module (`Autocomplete.module.scss`) that the rollup pipeline silently dropped — class names resolved to `undefined` and no CSS was emitted to the bundled stylesheet. Styles are now plain `nci-autocomplete*` classes shipped via `@nciocpl/react-components/styles`. diff --git a/src/components/ncids/Autocomplete/Autocomplete.module.scss b/src/components/ncids/Autocomplete/Autocomplete.module.scss deleted file mode 100644 index 3708e6e..0000000 --- a/src/components/ncids/Autocomplete/Autocomplete.module.scss +++ /dev/null @@ -1,78 +0,0 @@ -.autocomplete { - position: relative; - display: inline-block; -} - -.inputWrapper { - position: relative; - display: flex; - align-items: center; -} - -.autocompleteInput { - width: 100%; - padding-right: 2rem; -} - -.clearButton { - position: absolute; - right: 0.5rem; - top: 50%; - transform: translateY(-50%); - background: none; - border: none; - cursor: pointer; - font-size: 1.25rem; - line-height: 1; - padding: 0 0.25rem; - color: #71767a; - - &:hover { - color: #1b1b1b; - } - - &:focus { - outline: 0.25rem solid #2491ff; - outline-offset: 0; - } -} - -.listbox { - position: absolute; - z-index: 100; - top: 100%; - left: 0; - right: 0; - background-color: #ffffff; - border: 1px solid #a9aeb1; - border-top: none; - border-radius: 0 0 0.25rem 0.25rem; - list-style: none; - margin: 0; - padding: 0; - max-height: 15rem; - overflow-y: auto; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); -} - -.option { - padding: 0.5rem 1rem; - cursor: pointer; - font-size: 1rem; - - &:hover { - background-color: #f0f0f0; - } -} - -.optionHighlighted { - background-color: #d9e8f6; - outline: none; -} - -.statusMessage { - padding: 0.5rem 1rem; - color: #71767a; - font-style: italic; - cursor: default; -} diff --git a/src/components/ncids/Autocomplete/Autocomplete.stories.tsx b/src/components/ncids/Autocomplete/Autocomplete.stories.tsx index 9ef720b..c7058db 100644 --- a/src/components/ncids/Autocomplete/Autocomplete.stories.tsx +++ b/src/components/ncids/Autocomplete/Autocomplete.stories.tsx @@ -34,6 +34,12 @@ const meta: Meta> = { control: { type: 'number', min: 0, max: 1000, step: 50 }, description: 'Debounce delay in milliseconds', }, + minChars: { + control: { type: 'number', min: 0, max: 5, step: 1 }, + description: 'Minimum characters before options load', + }, + minCharsMessage: { control: 'text' }, + highlightMatch: { control: 'boolean' }, disabled: { control: 'boolean' }, noOptionsMessage: { control: 'text' }, loadingMessage: { control: 'text' }, @@ -55,6 +61,24 @@ export const Default: Story = { }, }; +/** + * Search-box configuration: a search (submit) button, a 3-character minimum, + * and bolded matches — mirrors the site banner search behaviour. + */ +export const SearchBox: Story = { + args: { + id: 'fruit-search', + label: 'Search', + options: fruits, + placeholder: 'Search…', + minChars: 3, + highlightMatch: true, + searchButtonLabel: 'Search', + onChange: fn(), + onSubmit: fn(), + }, +}; + /** Asynchronous data loading with a simulated 400 ms network delay. */ export const AsyncLoadOptions: Story = { args: { @@ -118,15 +142,13 @@ export const Disabled: Story = { }; /** Controlled component — the parent manages the selected value. */ -const ControlledTemplate = (args: React.ComponentProps>) => { +const ControlledTemplate = ( + args: React.ComponentProps> +) => { const [value, setValue] = useState(null); return (
- setValue(opt)} - /> + setValue(opt)} />

Selected:{' '} {value ? `${value.label} (${value.value})` : 'none'} diff --git a/src/components/ncids/Autocomplete/Autocomplete.test.tsx b/src/components/ncids/Autocomplete/Autocomplete.test.tsx index 75e9ceb..b14e3e9 100644 --- a/src/components/ncids/Autocomplete/Autocomplete.test.tsx +++ b/src/components/ncids/Autocomplete/Autocomplete.test.tsx @@ -661,6 +661,205 @@ describe('', () => { ); }); + // ── Minimum characters ────────────────────────────────────────────────────── + + it('shows the default min-chars message below the threshold', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render( + + ); + + await user.type(screen.getByRole('combobox'), 'ap'); + vi.runAllTimers(); + + await waitFor(() => + expect(screen.getByRole('listbox')).toBeInTheDocument() + ); + expect( + screen.getByText('Please enter 3 or more characters') + ).toBeInTheDocument(); + // loadOptions must not be called below the threshold + expect(loadFruits).not.toHaveBeenCalled(); + }); + + it('shows a custom min-chars message', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render( + + ); + + await user.type(screen.getByRole('combobox'), 'ap'); + vi.runAllTimers(); + + await waitFor(() => + expect(screen.getByText('Ingrese 3 o más caracteres')).toBeInTheDocument() + ); + }); + + it('loads options once the min-chars threshold is met', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render( + + ); + + await user.type(screen.getByRole('combobox'), 'app'); + vi.runAllTimers(); + + await waitFor(() => expect(loadFruits).toHaveBeenCalledWith('app')); + await waitFor(() => expect(screen.getByText('Apple')).toBeInTheDocument()); + }); + + // ── Search submit ──────────────────────────────────────────────────────────── + + it('does not render the search button without onSubmit', () => { + render(); + expect( + screen.queryByRole('button', { name: 'Search' }) + ).not.toBeInTheDocument(); + }); + + it('renders the search button when onSubmit is provided', () => { + render( + + ); + expect(screen.getByRole('button', { name: 'Search' })).toBeInTheDocument(); + }); + + it('uses a custom search button label', () => { + render( + + ); + expect(screen.getByRole('button', { name: 'Buscar' })).toBeInTheDocument(); + }); + + it('calls onSubmit with the input value when the search button is clicked', async () => { + const handleSubmit = vi.fn(); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render( + + ); + + await user.type(screen.getByRole('combobox'), 'ban'); + await user.click(screen.getByRole('button', { name: 'Search' })); + expect(handleSubmit).toHaveBeenCalledWith('ban'); + }); + + it('calls onSubmit when Enter is pressed with the dropdown closed', async () => { + const handleSubmit = vi.fn(); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render( + + ); + + const input = screen.getByRole('combobox'); + await user.type(input, 'ban'); + vi.runAllTimers(); + await waitFor(() => + expect(screen.getByRole('listbox')).toBeInTheDocument() + ); + + // Close the dropdown, then submit with Enter. + await user.keyboard('{Escape}'); + await user.keyboard('{Enter}'); + expect(handleSubmit).toHaveBeenCalledWith('ban'); + }); + + it('selects a highlighted option on Enter without submitting, then submits on next Enter', async () => { + window.HTMLElement.prototype.scrollIntoView = function () {}; + const handleSubmit = vi.fn(); + const handleChange = vi.fn(); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render( + + ); + + await user.type(screen.getByRole('combobox'), 'ban'); + vi.runAllTimers(); + await waitFor(() => + expect(screen.getByRole('listbox')).toBeInTheDocument() + ); + + // Highlight + Enter selects (no submit yet) + await user.keyboard('{ArrowDown}{Enter}'); + expect(handleChange).toHaveBeenCalledWith({ + label: 'Banana', + value: 'banana', + }); + expect(handleSubmit).not.toHaveBeenCalled(); + expect(screen.getByRole('combobox')).toHaveValue('Banana'); + + // Second Enter (dropdown closed) submits the selected value + await user.keyboard('{Enter}'); + expect(handleSubmit).toHaveBeenCalledWith('Banana'); + }); + + // ── Match highlighting ─────────────────────────────────────────────────────── + + it('bolds the matched substring when highlightMatch is set', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render( + + ); + + await user.type(screen.getByRole('combobox'), 'ap'); + vi.runAllTimers(); + await waitFor(() => + expect(screen.getByRole('listbox')).toBeInTheDocument() + ); + + const options = screen.getAllByRole('option'); + const appleOption = options.find( + (o) => o.textContent === 'Apple' + ) as HTMLElement; + expect(appleOption).toBeDefined(); + const strong = appleOption.querySelector('strong'); + expect(strong).not.toBeNull(); + expect(strong).toHaveTextContent('Ap'); + }); + // ── Accessibility ────────────────────────────────────────────────────────── it('has no accessibility violations in the default (closed) state', async () => { diff --git a/src/components/ncids/Autocomplete/Autocomplete.tsx b/src/components/ncids/Autocomplete/Autocomplete.tsx index 5791f49..c5910a9 100644 --- a/src/components/ncids/Autocomplete/Autocomplete.tsx +++ b/src/components/ncids/Autocomplete/Autocomplete.tsx @@ -1,7 +1,5 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; -import styles from './Autocomplete.module.scss'; - export interface AutocompleteProps { /** Input element ID */ id: string; @@ -16,8 +14,24 @@ export interface AutocompleteProps { loadOptions?: (inputValue: string) => Promise; /** Debounce delay in milliseconds before calling loadOptions. Defaults to 300. */ debounceDelay?: number; + /** + * Minimum number of characters required before options are loaded/filtered. + * While the input has between 1 and `minChars - 1` characters, the dropdown + * shows `minCharsMessage` instead. Defaults to 0 (no minimum). + */ + minChars?: number; + /** + * Message shown in the dropdown when the input has fewer than `minChars` + * characters. Defaults to "Please enter {minChars} or more characters". + */ + minCharsMessage?: string; /** Custom renderer for each option row */ renderOption?: (option: T, isHighlighted: boolean) => React.ReactNode; + /** + * When true (and no `renderOption` is provided), the portion of each option + * label matching the current input value is wrapped in ``. + */ + highlightMatch?: boolean; /** Extract the display label string from an option. Defaults to `option.label`. */ getOptionLabel?: (option: T) => string; /** Extract the unique value string from an option. Defaults to `option.value`. */ @@ -30,6 +44,14 @@ export interface AutocompleteProps { loadingMessage?: string; /** Called when the user selects an option or clears the input */ onChange?: (value: T | null) => void; + /** + * Called when the user submits a search — clicks the search button, or + * presses Enter without a highlighted option. Receives the raw input value. + * Providing this also renders the search (submit) button. + */ + onSubmit?: (inputValue: string) => void; + /** Accessible label for the search/submit button. Defaults to "Search". */ + searchButtonLabel?: string; /** Controlled currently-selected value */ value?: T | null; /** Disable the input */ @@ -48,19 +70,59 @@ export interface AutocompleteOption { const defaultGetLabel = (option: AutocompleteOption) => option.label; const defaultGetValue = (option: AutocompleteOption) => option.value; +/** + * Split `label` into plain text / `` segments around every + * case-insensitive occurrence of `query`. + */ +function highlightLabel(label: string, query: string): React.ReactNode { + const q = query.trim(); + if (!q) return label; + + const lowerLabel = label.toLowerCase(); + const lowerQuery = q.toLowerCase(); + const segments: React.ReactNode[] = []; + let cursor = 0; + let matchIndex = lowerLabel.indexOf(lowerQuery); + let key = 0; + + while (matchIndex !== -1) { + if (matchIndex > cursor) { + segments.push(label.slice(cursor, matchIndex)); + } + segments.push( + + {label.slice(matchIndex, matchIndex + q.length)} + + ); + cursor = matchIndex + q.length; + matchIndex = lowerLabel.indexOf(lowerQuery, cursor); + } + + if (cursor < label.length) { + segments.push(label.slice(cursor)); + } + + return segments; +} + export function Autocomplete({ id, label, options, loadOptions, debounceDelay = 300, + minChars = 0, + minCharsMessage, renderOption, + highlightMatch = false, getOptionLabel, getOptionValue, placeholder, noOptionsMessage = 'No results found.', loadingMessage = 'Loading…', onChange, + onSubmit, + searchButtonLabel = 'Search', value, disabled = false, className, @@ -93,6 +155,18 @@ export function Autocomplete({ const debounceTimerRef = useRef | null>(null); const isMountedRef = useRef(true); + const resolvedMinCharsMessage = + minCharsMessage ?? `Please enter ${minChars} or more characters`; + + // True when the input has some text but fewer than the required minimum. + const belowMinChars = useCallback( + (query: string) => { + const len = query.trim().length; + return minChars > 0 && len > 0 && len < minChars; + }, + [minChars] + ); + // Sync controlled value changes useEffect(() => { if (value !== undefined) { @@ -110,6 +184,15 @@ export function Autocomplete({ const openDropdown = useCallback( async (query: string) => { + // Below the minimum character threshold: surface the hint instead of + // loading/filtering options. + if (belowMinChars(query)) { + setIsLoading(false); + setFilteredOptions([]); + setIsOpen(true); + return; + } + if (loadOptions) { setIsLoading(true); setIsOpen(true); @@ -132,7 +215,7 @@ export function Autocomplete({ setIsOpen(true); } }, - [loadOptions, options, resolveLabel] + [belowMinChars, loadOptions, options, resolveLabel] ); const handleInputChange = useCallback( @@ -157,11 +240,20 @@ export function Autocomplete({ return; } + // Below the minimum threshold: show the hint immediately (no debounce, + // no network request). + if (belowMinChars(newValue)) { + setIsLoading(false); + setFilteredOptions([]); + setIsOpen(true); + return; + } + debounceTimerRef.current = setTimeout(() => { openDropdown(newValue); }, debounceDelay); }, - [debounceDelay, onChange, openDropdown, selectedValue] + [belowMinChars, debounceDelay, onChange, openDropdown, selectedValue] ); const selectOption = useCallback( @@ -186,6 +278,12 @@ export function Autocomplete({ inputRef.current?.focus(); }, [onChange]); + const handleSubmit = useCallback(() => { + setIsOpen(false); + setHighlightedIndex(-1); + onSubmit?.(inputValue); + }, [inputValue, onSubmit]); + const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (!isOpen) { @@ -194,6 +292,9 @@ export function Autocomplete({ if (inputValue.trim()) { openDropdown(inputValue); } + } else if (e.key === 'Enter') { + e.preventDefault(); + handleSubmit(); } return; } @@ -218,6 +319,9 @@ export function Autocomplete({ highlightedIndex < filteredOptions.length ) { selectOption(filteredOptions[highlightedIndex]); + } else { + // No option highlighted — treat Enter as a search submission. + handleSubmit(); } break; } @@ -236,6 +340,7 @@ export function Autocomplete({ }, [ filteredOptions, + handleSubmit, highlightedIndex, inputValue, isOpen, @@ -283,51 +388,79 @@ export function Autocomplete({ const activeDescendant = isOpen && highlightedIndex >= 0 ? optionId(highlightedIndex) : undefined; - const wrapperClasses = [styles.autocomplete, className || ''] + const wrapperClasses = ['nci-autocomplete', className || ''] .filter(Boolean) .join(' '); const inputClasses = [ 'usa-input', - styles.autocompleteInput, + 'nci-autocomplete__input', inputClassName || '', ] .filter(Boolean) .join(' '); + const renderOptionLabel = (option: T, isHighlighted: boolean) => { + if (renderOption) { + return renderOption(option, isHighlighted); + } + const text = resolveLabel(option); + return highlightMatch ? highlightLabel(text, inputValue) : text; + }; + return (

-
- - {inputValue && !disabled && ( +
+
+ + {inputValue && !disabled && ( + + )} +
+ {onSubmit && ( )}
@@ -337,16 +470,25 @@ export function Autocomplete({ id={listboxId} role="listbox" aria-labelledby={labelId} - className={styles.listbox} + className="nci-autocomplete__listbox" hidden={!isOpen} > {isOpen && - (isLoading ? ( + (belowMinChars(inputValue) ? ( +
  • + {resolvedMinCharsMessage} +
  • + ) : isLoading ? (
  • {loadingMessage}
  • @@ -355,7 +497,7 @@ export function Autocomplete({ role="option" aria-selected={false} aria-disabled={true} - className={styles.statusMessage} + className="nci-autocomplete__status" > {noOptionsMessage} @@ -372,8 +514,10 @@ export function Autocomplete({ role="option" aria-selected={isSelected} className={[ - styles.option, - isHighlighted ? styles.optionHighlighted : '', + 'nci-autocomplete__option', + isHighlighted + ? 'nci-autocomplete__option--highlighted' + : '', ] .filter(Boolean) .join(' ')} @@ -383,9 +527,7 @@ export function Autocomplete({ }} onClick={() => selectOption(option)} > - {renderOption - ? renderOption(option, isHighlighted) - : resolveLabel(option)} + {renderOptionLabel(option, isHighlighted)} ); }) diff --git a/src/styles/_autocomplete.scss b/src/styles/_autocomplete.scss new file mode 100644 index 0000000..64e3d5e --- /dev/null +++ b/src/styles/_autocomplete.scss @@ -0,0 +1,130 @@ +// Styles for the NCIDS Autocomplete component. +// +// Plain (non-module) class names so the rules ship in the bundled +// stylesheet (`@nciocpl/react-components/styles`). The component renders +// these exact class names; do not rename without updating Autocomplete.tsx. + +.nci-autocomplete { + position: relative; + display: block; + width: 100%; +} + +.nci-autocomplete__control { + display: flex; + align-items: stretch; +} + +.nci-autocomplete__field { + position: relative; + flex: 1 1 auto; + display: flex; + align-items: center; +} + +.nci-autocomplete__input { + width: 100%; + // Leave room for the clear (×) button on the right. + padding-right: 2rem; +} + +.nci-autocomplete__clear { + position: absolute; + right: 0.5rem; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + cursor: pointer; + font-size: 1.25rem; + line-height: 1; + padding: 0 0.25rem; + color: #71767a; + + &:hover { + color: #1b1b1b; + } + + &:focus { + outline: 0.25rem solid #2491ff; + outline-offset: 0; + } +} + +.nci-autocomplete__submit { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 3rem; + margin: 0; + padding: 0 1rem; + border: none; + background-color: #005ea2; + color: #ffffff; + cursor: pointer; + + &:hover { + background-color: #1a4480; + } + + &:focus { + outline: 0.25rem solid #2491ff; + outline-offset: 0; + } + + &:disabled { + background-color: #c9c9c9; + cursor: not-allowed; + } + + svg { + width: 1.5rem; + height: 1.5rem; + fill: currentColor; + } +} + +.nci-autocomplete__listbox { + position: absolute; + z-index: 100; + top: 100%; + left: 0; + right: 0; + background-color: #ffffff; + border: 1px solid #a9aeb1; + border-top: none; + border-radius: 0 0 0.25rem 0.25rem; + list-style: none; + margin: 0; + padding: 0; + max-height: 15rem; + overflow-y: auto; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +.nci-autocomplete__option { + padding: 0.5rem 1rem; + cursor: pointer; + font-size: 1rem; + + &:hover { + background-color: #f0f0f0; + } + + strong { + font-weight: 700; + } +} + +.nci-autocomplete__option--highlighted { + background-color: #d9e8f6; + outline: none; +} + +.nci-autocomplete__status { + padding: 0.5rem 1rem; + color: #71767a; + font-style: italic; + cursor: default; +} diff --git a/src/styles/index.scss b/src/styles/index.scss index 3082717..cd83811 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -17,3 +17,6 @@ @forward 'usa-pagination'; @forward 'usa-icon'; @forward 'usa-collection'; + +// Library component styles (plain CSS, not USWDS packages). +@forward 'autocomplete';