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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/autocomplete-component.md
Original file line number Diff line number Diff line change
@@ -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`.
78 changes: 0 additions & 78 deletions src/components/ncids/Autocomplete/Autocomplete.module.scss

This file was deleted.

34 changes: 28 additions & 6 deletions src/components/ncids/Autocomplete/Autocomplete.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ const meta: Meta<typeof Autocomplete<AutocompleteOption>> = {
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' },
Expand All @@ -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: {
Expand Down Expand Up @@ -118,15 +142,13 @@ export const Disabled: Story = {
};

/** Controlled component — the parent manages the selected value. */
const ControlledTemplate = (args: React.ComponentProps<typeof Autocomplete<AutocompleteOption>>) => {
const ControlledTemplate = (
args: React.ComponentProps<typeof Autocomplete<AutocompleteOption>>
) => {
const [value, setValue] = useState<AutocompleteOption | null>(null);
return (
<div>
<Autocomplete
{...args}
value={value}
onChange={(opt) => setValue(opt)}
/>
<Autocomplete {...args} value={value} onChange={(opt) => setValue(opt)} />
<p style={{ marginTop: '1rem' }}>
Selected:{' '}
<strong>{value ? `${value.label} (${value.value})` : 'none'}</strong>
Expand Down
199 changes: 199 additions & 0 deletions src/components/ncids/Autocomplete/Autocomplete.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,205 @@ describe('<Autocomplete />', () => {
);
});

// ── Minimum characters ──────────────────────────────────────────────────────

it('shows the default min-chars message below the threshold', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(
<Autocomplete
id="fruit"
label="Fruit"
loadOptions={loadFruits}
minChars={3}
/>
);

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(
<Autocomplete
id="fruit"
label="Fruit"
options={fruits}
minChars={3}
minCharsMessage="Ingrese 3 o más caracteres"
/>
);

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(
<Autocomplete
id="fruit"
label="Fruit"
loadOptions={loadFruits}
minChars={3}
/>
);

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(<Autocomplete id="fruit" label="Fruit" options={fruits} />);
expect(
screen.queryByRole('button', { name: 'Search' })
).not.toBeInTheDocument();
});

it('renders the search button when onSubmit is provided', () => {
render(
<Autocomplete
id="fruit"
label="Fruit"
options={fruits}
onSubmit={vi.fn()}
/>
);
expect(screen.getByRole('button', { name: 'Search' })).toBeInTheDocument();
});

it('uses a custom search button label', () => {
render(
<Autocomplete
id="fruit"
label="Fruit"
options={fruits}
onSubmit={vi.fn()}
searchButtonLabel="Buscar"
/>
);
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(
<Autocomplete
id="fruit"
label="Fruit"
options={fruits}
onSubmit={handleSubmit}
/>
);

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(
<Autocomplete
id="fruit"
label="Fruit"
options={fruits}
onSubmit={handleSubmit}
/>
);

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(
<Autocomplete
id="fruit"
label="Fruit"
options={fruits}
onSubmit={handleSubmit}
onChange={handleChange}
/>
);

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(
<Autocomplete id="fruit" label="Fruit" options={fruits} highlightMatch />
);

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 () => {
Expand Down
Loading
Loading