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/.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/.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.stories.tsx b/src/components/ncids/Autocomplete/Autocomplete.stories.tsx new file mode 100644 index 0000000..c7058db --- /dev/null +++ b/src/components/ncids/Autocomplete/Autocomplete.stories.tsx @@ -0,0 +1,212 @@ +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', + }, + 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' }, + 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(), + }, +}; + +/** + * 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: { + 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..b14e3e9 --- /dev/null +++ b/src/components/ncids/Autocomplete/Autocomplete.test.tsx @@ -0,0 +1,890 @@ +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 + ); + }); + + // ── 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 () => { + 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..c5910a9 --- /dev/null +++ b/src/components/ncids/Autocomplete/Autocomplete.tsx @@ -0,0 +1,540 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; + +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; + /** + * 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`. */ + 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; + /** + * 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 */ + 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; + +/** + * 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, + 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); + + 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) { + setSelectedValue(value ?? null); + setInputValue(value != null ? resolveLabel(value) : ''); + } + }, [value, resolveLabel]); + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + + 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); + 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); + } + }, + [belowMinChars, 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; + } + + // 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); + }, + [belowMinChars, 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 handleSubmit = useCallback(() => { + setIsOpen(false); + setHighlightedIndex(-1); + onSubmit?.(inputValue); + }, [inputValue, onSubmit]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (!isOpen) { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + if (inputValue.trim()) { + openDropdown(inputValue); + } + } else if (e.key === 'Enter') { + e.preventDefault(); + handleSubmit(); + } + 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]); + } else { + // No option highlighted — treat Enter as a search submission. + handleSubmit(); + } + break; + } + case 'Escape': { + e.preventDefault(); + setIsOpen(false); + setHighlightedIndex(-1); + break; + } + case 'Tab': { + setIsOpen(false); + setHighlightedIndex(-1); + break; + } + } + }, + [ + filteredOptions, + handleSubmit, + 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 = ['nci-autocomplete', className || ''] + .filter(Boolean) + .join(' '); + + const inputClasses = [ + 'usa-input', + '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 ( + + + {label} + + + + + {inputValue && !disabled && ( + + × + + )} + + {onSubmit && ( + + + + + + )} + + + + {isOpen && + (belowMinChars(inputValue) ? ( + + {resolvedMinCharsMessage} + + ) : isLoading ? ( + + {loadingMessage} + + ) : filteredOptions.length === 0 ? ( + + {noOptionsMessage} + + ) : ( + filteredOptions.map((option, index) => { + const isHighlighted = index === highlightedIndex; + const isSelected = + selectedValue !== null && + resolveValue(option) === resolveValue(selectedValue); + return ( + { + // Prevent input blur before click registers + e.preventDefault(); + }} + onClick={() => selectOption(option)} + > + {renderOptionLabel(option, isHighlighted)} + + ); + }) + ))} + + + ); +} + +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/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..36e10b4 100644 --- a/src/components/ncids/index.ts +++ b/src/components/ncids/index.ts @@ -5,3 +5,7 @@ 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'; +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'; 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';
+ Selected:{' '} + {value ? `${value.label} (${value.value})` : 'none'} +
+ Selected: {value.name} ({value.code}) +