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';