setValue(opt)} />
Selected:{' '}
{value ? `${value.label} (${value.value})` : 'none'}
diff --git a/src/components/ncids/Autocomplete/Autocomplete.test.tsx b/src/components/ncids/Autocomplete/Autocomplete.test.tsx
index 75e9ceb..b14e3e9 100644
--- a/src/components/ncids/Autocomplete/Autocomplete.test.tsx
+++ b/src/components/ncids/Autocomplete/Autocomplete.test.tsx
@@ -661,6 +661,205 @@ describe('', () => {
);
});
+ // ── Minimum characters ──────────────────────────────────────────────────────
+
+ it('shows the default min-chars message below the threshold', async () => {
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
+ render(
+
+ );
+
+ await user.type(screen.getByRole('combobox'), 'ap');
+ vi.runAllTimers();
+
+ await waitFor(() =>
+ expect(screen.getByRole('listbox')).toBeInTheDocument()
+ );
+ expect(
+ screen.getByText('Please enter 3 or more characters')
+ ).toBeInTheDocument();
+ // loadOptions must not be called below the threshold
+ expect(loadFruits).not.toHaveBeenCalled();
+ });
+
+ it('shows a custom min-chars message', async () => {
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
+ render(
+
+ );
+
+ await user.type(screen.getByRole('combobox'), 'ap');
+ vi.runAllTimers();
+
+ await waitFor(() =>
+ expect(screen.getByText('Ingrese 3 o más caracteres')).toBeInTheDocument()
+ );
+ });
+
+ it('loads options once the min-chars threshold is met', async () => {
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
+ render(
+
+ );
+
+ await user.type(screen.getByRole('combobox'), 'app');
+ vi.runAllTimers();
+
+ await waitFor(() => expect(loadFruits).toHaveBeenCalledWith('app'));
+ await waitFor(() => expect(screen.getByText('Apple')).toBeInTheDocument());
+ });
+
+ // ── Search submit ────────────────────────────────────────────────────────────
+
+ it('does not render the search button without onSubmit', () => {
+ render();
+ expect(
+ screen.queryByRole('button', { name: 'Search' })
+ ).not.toBeInTheDocument();
+ });
+
+ it('renders the search button when onSubmit is provided', () => {
+ render(
+
+ );
+ expect(screen.getByRole('button', { name: 'Search' })).toBeInTheDocument();
+ });
+
+ it('uses a custom search button label', () => {
+ render(
+
+ );
+ expect(screen.getByRole('button', { name: 'Buscar' })).toBeInTheDocument();
+ });
+
+ it('calls onSubmit with the input value when the search button is clicked', async () => {
+ const handleSubmit = vi.fn();
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
+ render(
+
+ );
+
+ await user.type(screen.getByRole('combobox'), 'ban');
+ await user.click(screen.getByRole('button', { name: 'Search' }));
+ expect(handleSubmit).toHaveBeenCalledWith('ban');
+ });
+
+ it('calls onSubmit when Enter is pressed with the dropdown closed', async () => {
+ const handleSubmit = vi.fn();
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
+ render(
+
+ );
+
+ const input = screen.getByRole('combobox');
+ await user.type(input, 'ban');
+ vi.runAllTimers();
+ await waitFor(() =>
+ expect(screen.getByRole('listbox')).toBeInTheDocument()
+ );
+
+ // Close the dropdown, then submit with Enter.
+ await user.keyboard('{Escape}');
+ await user.keyboard('{Enter}');
+ expect(handleSubmit).toHaveBeenCalledWith('ban');
+ });
+
+ it('selects a highlighted option on Enter without submitting, then submits on next Enter', async () => {
+ window.HTMLElement.prototype.scrollIntoView = function () {};
+ const handleSubmit = vi.fn();
+ const handleChange = vi.fn();
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
+ render(
+
+ );
+
+ await user.type(screen.getByRole('combobox'), 'ban');
+ vi.runAllTimers();
+ await waitFor(() =>
+ expect(screen.getByRole('listbox')).toBeInTheDocument()
+ );
+
+ // Highlight + Enter selects (no submit yet)
+ await user.keyboard('{ArrowDown}{Enter}');
+ expect(handleChange).toHaveBeenCalledWith({
+ label: 'Banana',
+ value: 'banana',
+ });
+ expect(handleSubmit).not.toHaveBeenCalled();
+ expect(screen.getByRole('combobox')).toHaveValue('Banana');
+
+ // Second Enter (dropdown closed) submits the selected value
+ await user.keyboard('{Enter}');
+ expect(handleSubmit).toHaveBeenCalledWith('Banana');
+ });
+
+ // ── Match highlighting ───────────────────────────────────────────────────────
+
+ it('bolds the matched substring when highlightMatch is set', async () => {
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
+ render(
+
+ );
+
+ await user.type(screen.getByRole('combobox'), 'ap');
+ vi.runAllTimers();
+ await waitFor(() =>
+ expect(screen.getByRole('listbox')).toBeInTheDocument()
+ );
+
+ const options = screen.getAllByRole('option');
+ const appleOption = options.find(
+ (o) => o.textContent === 'Apple'
+ ) as HTMLElement;
+ expect(appleOption).toBeDefined();
+ const strong = appleOption.querySelector('strong');
+ expect(strong).not.toBeNull();
+ expect(strong).toHaveTextContent('Ap');
+ });
+
// ── Accessibility ──────────────────────────────────────────────────────────
it('has no accessibility violations in the default (closed) state', async () => {
diff --git a/src/components/ncids/Autocomplete/Autocomplete.tsx b/src/components/ncids/Autocomplete/Autocomplete.tsx
index 5791f49..c5910a9 100644
--- a/src/components/ncids/Autocomplete/Autocomplete.tsx
+++ b/src/components/ncids/Autocomplete/Autocomplete.tsx
@@ -1,7 +1,5 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
-import styles from './Autocomplete.module.scss';
-
export interface AutocompleteProps {
/** Input element ID */
id: string;
@@ -16,8 +14,24 @@ export interface AutocompleteProps {
loadOptions?: (inputValue: string) => Promise;
/** Debounce delay in milliseconds before calling loadOptions. Defaults to 300. */
debounceDelay?: number;
+ /**
+ * Minimum number of characters required before options are loaded/filtered.
+ * While the input has between 1 and `minChars - 1` characters, the dropdown
+ * shows `minCharsMessage` instead. Defaults to 0 (no minimum).
+ */
+ minChars?: number;
+ /**
+ * Message shown in the dropdown when the input has fewer than `minChars`
+ * characters. Defaults to "Please enter {minChars} or more characters".
+ */
+ minCharsMessage?: string;
/** Custom renderer for each option row */
renderOption?: (option: T, isHighlighted: boolean) => React.ReactNode;
+ /**
+ * When true (and no `renderOption` is provided), the portion of each option
+ * label matching the current input value is wrapped in ``.
+ */
+ highlightMatch?: boolean;
/** Extract the display label string from an option. Defaults to `option.label`. */
getOptionLabel?: (option: T) => string;
/** Extract the unique value string from an option. Defaults to `option.value`. */
@@ -30,6 +44,14 @@ export interface AutocompleteProps {
loadingMessage?: string;
/** Called when the user selects an option or clears the input */
onChange?: (value: T | null) => void;
+ /**
+ * Called when the user submits a search — clicks the search button, or
+ * presses Enter without a highlighted option. Receives the raw input value.
+ * Providing this also renders the search (submit) button.
+ */
+ onSubmit?: (inputValue: string) => void;
+ /** Accessible label for the search/submit button. Defaults to "Search". */
+ searchButtonLabel?: string;
/** Controlled currently-selected value */
value?: T | null;
/** Disable the input */
@@ -48,19 +70,59 @@ export interface AutocompleteOption {
const defaultGetLabel = (option: AutocompleteOption) => option.label;
const defaultGetValue = (option: AutocompleteOption) => option.value;
+/**
+ * Split `label` into plain text / `` segments around every
+ * case-insensitive occurrence of `query`.
+ */
+function highlightLabel(label: string, query: string): React.ReactNode {
+ const q = query.trim();
+ if (!q) return label;
+
+ const lowerLabel = label.toLowerCase();
+ const lowerQuery = q.toLowerCase();
+ const segments: React.ReactNode[] = [];
+ let cursor = 0;
+ let matchIndex = lowerLabel.indexOf(lowerQuery);
+ let key = 0;
+
+ while (matchIndex !== -1) {
+ if (matchIndex > cursor) {
+ segments.push(label.slice(cursor, matchIndex));
+ }
+ segments.push(
+
+ {label.slice(matchIndex, matchIndex + q.length)}
+
+ );
+ cursor = matchIndex + q.length;
+ matchIndex = lowerLabel.indexOf(lowerQuery, cursor);
+ }
+
+ if (cursor < label.length) {
+ segments.push(label.slice(cursor));
+ }
+
+ return segments;
+}
+
export function Autocomplete({
id,
label,
options,
loadOptions,
debounceDelay = 300,
+ minChars = 0,
+ minCharsMessage,
renderOption,
+ highlightMatch = false,
getOptionLabel,
getOptionValue,
placeholder,
noOptionsMessage = 'No results found.',
loadingMessage = 'Loading…',
onChange,
+ onSubmit,
+ searchButtonLabel = 'Search',
value,
disabled = false,
className,
@@ -93,6 +155,18 @@ export function Autocomplete({
const debounceTimerRef = useRef | null>(null);
const isMountedRef = useRef(true);
+ const resolvedMinCharsMessage =
+ minCharsMessage ?? `Please enter ${minChars} or more characters`;
+
+ // True when the input has some text but fewer than the required minimum.
+ const belowMinChars = useCallback(
+ (query: string) => {
+ const len = query.trim().length;
+ return minChars > 0 && len > 0 && len < minChars;
+ },
+ [minChars]
+ );
+
// Sync controlled value changes
useEffect(() => {
if (value !== undefined) {
@@ -110,6 +184,15 @@ export function Autocomplete({
const openDropdown = useCallback(
async (query: string) => {
+ // Below the minimum character threshold: surface the hint instead of
+ // loading/filtering options.
+ if (belowMinChars(query)) {
+ setIsLoading(false);
+ setFilteredOptions([]);
+ setIsOpen(true);
+ return;
+ }
+
if (loadOptions) {
setIsLoading(true);
setIsOpen(true);
@@ -132,7 +215,7 @@ export function Autocomplete({
setIsOpen(true);
}
},
- [loadOptions, options, resolveLabel]
+ [belowMinChars, loadOptions, options, resolveLabel]
);
const handleInputChange = useCallback(
@@ -157,11 +240,20 @@ export function Autocomplete({
return;
}
+ // Below the minimum threshold: show the hint immediately (no debounce,
+ // no network request).
+ if (belowMinChars(newValue)) {
+ setIsLoading(false);
+ setFilteredOptions([]);
+ setIsOpen(true);
+ return;
+ }
+
debounceTimerRef.current = setTimeout(() => {
openDropdown(newValue);
}, debounceDelay);
},
- [debounceDelay, onChange, openDropdown, selectedValue]
+ [belowMinChars, debounceDelay, onChange, openDropdown, selectedValue]
);
const selectOption = useCallback(
@@ -186,6 +278,12 @@ export function Autocomplete({
inputRef.current?.focus();
}, [onChange]);
+ const handleSubmit = useCallback(() => {
+ setIsOpen(false);
+ setHighlightedIndex(-1);
+ onSubmit?.(inputValue);
+ }, [inputValue, onSubmit]);
+
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (!isOpen) {
@@ -194,6 +292,9 @@ export function Autocomplete({
if (inputValue.trim()) {
openDropdown(inputValue);
}
+ } else if (e.key === 'Enter') {
+ e.preventDefault();
+ handleSubmit();
}
return;
}
@@ -218,6 +319,9 @@ export function Autocomplete({
highlightedIndex < filteredOptions.length
) {
selectOption(filteredOptions[highlightedIndex]);
+ } else {
+ // No option highlighted — treat Enter as a search submission.
+ handleSubmit();
}
break;
}
@@ -236,6 +340,7 @@ export function Autocomplete({
},
[
filteredOptions,
+ handleSubmit,
highlightedIndex,
inputValue,
isOpen,
@@ -283,51 +388,79 @@ export function Autocomplete({
const activeDescendant =
isOpen && highlightedIndex >= 0 ? optionId(highlightedIndex) : undefined;
- const wrapperClasses = [styles.autocomplete, className || '']
+ const wrapperClasses = ['nci-autocomplete', className || '']
.filter(Boolean)
.join(' ');
const inputClasses = [
'usa-input',
- styles.autocompleteInput,
+ 'nci-autocomplete__input',
inputClassName || '',
]
.filter(Boolean)
.join(' ');
+ const renderOptionLabel = (option: T, isHighlighted: boolean) => {
+ if (renderOption) {
+ return renderOption(option, isHighlighted);
+ }
+ const text = resolveLabel(option);
+ return highlightMatch ? highlightLabel(text, inputValue) : text;
+ };
+
return (
-
-
- {inputValue && !disabled && (
+
+
+
+ {inputValue && !disabled && (
+
+ )}
+
+ {onSubmit && (
)}
@@ -337,16 +470,25 @@ export function Autocomplete
({
id={listboxId}
role="listbox"
aria-labelledby={labelId}
- className={styles.listbox}
+ className="nci-autocomplete__listbox"
hidden={!isOpen}
>
{isOpen &&
- (isLoading ? (
+ (belowMinChars(inputValue) ? (
+
+ {resolvedMinCharsMessage}
+
+ ) : isLoading ? (
{loadingMessage}
@@ -355,7 +497,7 @@ export function Autocomplete({
role="option"
aria-selected={false}
aria-disabled={true}
- className={styles.statusMessage}
+ className="nci-autocomplete__status"
>
{noOptionsMessage}
@@ -372,8 +514,10 @@ export function Autocomplete({
role="option"
aria-selected={isSelected}
className={[
- styles.option,
- isHighlighted ? styles.optionHighlighted : '',
+ 'nci-autocomplete__option',
+ isHighlighted
+ ? 'nci-autocomplete__option--highlighted'
+ : '',
]
.filter(Boolean)
.join(' ')}
@@ -383,9 +527,7 @@ export function Autocomplete({
}}
onClick={() => selectOption(option)}
>
- {renderOption
- ? renderOption(option, isHighlighted)
- : resolveLabel(option)}
+ {renderOptionLabel(option, isHighlighted)}
);
})
diff --git a/src/styles/_autocomplete.scss b/src/styles/_autocomplete.scss
new file mode 100644
index 0000000..64e3d5e
--- /dev/null
+++ b/src/styles/_autocomplete.scss
@@ -0,0 +1,130 @@
+// Styles for the NCIDS Autocomplete component.
+//
+// Plain (non-module) class names so the rules ship in the bundled
+// stylesheet (`@nciocpl/react-components/styles`). The component renders
+// these exact class names; do not rename without updating Autocomplete.tsx.
+
+.nci-autocomplete {
+ position: relative;
+ display: block;
+ width: 100%;
+}
+
+.nci-autocomplete__control {
+ display: flex;
+ align-items: stretch;
+}
+
+.nci-autocomplete__field {
+ position: relative;
+ flex: 1 1 auto;
+ display: flex;
+ align-items: center;
+}
+
+.nci-autocomplete__input {
+ width: 100%;
+ // Leave room for the clear (×) button on the right.
+ padding-right: 2rem;
+}
+
+.nci-autocomplete__clear {
+ position: absolute;
+ right: 0.5rem;
+ top: 50%;
+ transform: translateY(-50%);
+ background: none;
+ border: none;
+ cursor: pointer;
+ font-size: 1.25rem;
+ line-height: 1;
+ padding: 0 0.25rem;
+ color: #71767a;
+
+ &:hover {
+ color: #1b1b1b;
+ }
+
+ &:focus {
+ outline: 0.25rem solid #2491ff;
+ outline-offset: 0;
+ }
+}
+
+.nci-autocomplete__submit {
+ flex: 0 0 auto;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 3rem;
+ margin: 0;
+ padding: 0 1rem;
+ border: none;
+ background-color: #005ea2;
+ color: #ffffff;
+ cursor: pointer;
+
+ &:hover {
+ background-color: #1a4480;
+ }
+
+ &:focus {
+ outline: 0.25rem solid #2491ff;
+ outline-offset: 0;
+ }
+
+ &:disabled {
+ background-color: #c9c9c9;
+ cursor: not-allowed;
+ }
+
+ svg {
+ width: 1.5rem;
+ height: 1.5rem;
+ fill: currentColor;
+ }
+}
+
+.nci-autocomplete__listbox {
+ position: absolute;
+ z-index: 100;
+ top: 100%;
+ left: 0;
+ right: 0;
+ background-color: #ffffff;
+ border: 1px solid #a9aeb1;
+ border-top: none;
+ border-radius: 0 0 0.25rem 0.25rem;
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ max-height: 15rem;
+ overflow-y: auto;
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
+}
+
+.nci-autocomplete__option {
+ padding: 0.5rem 1rem;
+ cursor: pointer;
+ font-size: 1rem;
+
+ &:hover {
+ background-color: #f0f0f0;
+ }
+
+ strong {
+ font-weight: 700;
+ }
+}
+
+.nci-autocomplete__option--highlighted {
+ background-color: #d9e8f6;
+ outline: none;
+}
+
+.nci-autocomplete__status {
+ padding: 0.5rem 1rem;
+ color: #71767a;
+ font-style: italic;
+ cursor: default;
+}
diff --git a/src/styles/index.scss b/src/styles/index.scss
index 3082717..cd83811 100644
--- a/src/styles/index.scss
+++ b/src/styles/index.scss
@@ -17,3 +17,6 @@
@forward 'usa-pagination';
@forward 'usa-icon';
@forward 'usa-collection';
+
+// Library component styles (plain CSS, not USWDS packages).
+@forward 'autocomplete';