From 0de0270256302b577a06b8e6e92a9a9f56be9c1a Mon Sep 17 00:00:00 2001 From: adriancofie <38888889+adriancofie@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:15:33 -0400 Subject: [PATCH] (#21) Add Autocomplete search-box features and fix style packaging - Replace the broken CSS-module (Autocomplete.module.scss compiled to `var styles = undefined` and shipped no CSS) with a plain SCSS partial (_autocomplete.scss) forwarded into the bundled stylesheet, using static nci-autocomplete* class names so styles ship in dist/styles/index.css. - Add minChars + minCharsMessage (below-threshold hint). - Add onSubmit + a search button; Enter submits when no option is highlighted. - Add highlightMatch to bold the matched substring, and searchButtonLabel. - Tests (46) and a SearchBox story; add changeset (minor). Closes #21 --- .changeset/autocomplete-component.md | 8 + .../Autocomplete/Autocomplete.module.scss | 78 ------ .../Autocomplete/Autocomplete.stories.tsx | 34 ++- .../ncids/Autocomplete/Autocomplete.test.tsx | 199 ++++++++++++++++ .../ncids/Autocomplete/Autocomplete.tsx | 222 ++++++++++++++---- src/styles/_autocomplete.scss | 130 ++++++++++ src/styles/index.scss | 3 + 7 files changed, 550 insertions(+), 124 deletions(-) create mode 100644 .changeset/autocomplete-component.md delete mode 100644 src/components/ncids/Autocomplete/Autocomplete.module.scss create mode 100644 src/styles/_autocomplete.scss diff --git a/.changeset/autocomplete-component.md b/.changeset/autocomplete-component.md new file mode 100644 index 0000000..2cdf08a --- /dev/null +++ b/.changeset/autocomplete-component.md @@ -0,0 +1,8 @@ +--- +'@nciocpl/react-components': minor +--- + +Add `Autocomplete` (NCIDS combobox) component and fix its packaging. + +- **Search-box features:** new `minChars` / `minCharsMessage` props (shows a "enter N or more characters" hint below the threshold), an `onSubmit` callback plus a built-in search button (Enter submits when no option is highlighted; a highlighted option is selected first), `highlightMatch` to bold the matched substring in each option, and a `searchButtonLabel` for localization. +- **Styling now ships:** the component previously relied on a CSS-module (`Autocomplete.module.scss`) that the rollup pipeline silently dropped — class names resolved to `undefined` and no CSS was emitted to the bundled stylesheet. Styles are now plain `nci-autocomplete*` classes shipped via `@nciocpl/react-components/styles`. diff --git a/src/components/ncids/Autocomplete/Autocomplete.module.scss b/src/components/ncids/Autocomplete/Autocomplete.module.scss deleted file mode 100644 index 3708e6e..0000000 --- a/src/components/ncids/Autocomplete/Autocomplete.module.scss +++ /dev/null @@ -1,78 +0,0 @@ -.autocomplete { - position: relative; - display: inline-block; -} - -.inputWrapper { - position: relative; - display: flex; - align-items: center; -} - -.autocompleteInput { - width: 100%; - padding-right: 2rem; -} - -.clearButton { - position: absolute; - right: 0.5rem; - top: 50%; - transform: translateY(-50%); - background: none; - border: none; - cursor: pointer; - font-size: 1.25rem; - line-height: 1; - padding: 0 0.25rem; - color: #71767a; - - &:hover { - color: #1b1b1b; - } - - &:focus { - outline: 0.25rem solid #2491ff; - outline-offset: 0; - } -} - -.listbox { - position: absolute; - z-index: 100; - top: 100%; - left: 0; - right: 0; - background-color: #ffffff; - border: 1px solid #a9aeb1; - border-top: none; - border-radius: 0 0 0.25rem 0.25rem; - list-style: none; - margin: 0; - padding: 0; - max-height: 15rem; - overflow-y: auto; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); -} - -.option { - padding: 0.5rem 1rem; - cursor: pointer; - font-size: 1rem; - - &:hover { - background-color: #f0f0f0; - } -} - -.optionHighlighted { - background-color: #d9e8f6; - outline: none; -} - -.statusMessage { - padding: 0.5rem 1rem; - color: #71767a; - font-style: italic; - cursor: default; -} diff --git a/src/components/ncids/Autocomplete/Autocomplete.stories.tsx b/src/components/ncids/Autocomplete/Autocomplete.stories.tsx index 9ef720b..c7058db 100644 --- a/src/components/ncids/Autocomplete/Autocomplete.stories.tsx +++ b/src/components/ncids/Autocomplete/Autocomplete.stories.tsx @@ -34,6 +34,12 @@ const meta: Meta> = { control: { type: 'number', min: 0, max: 1000, step: 50 }, description: 'Debounce delay in milliseconds', }, + minChars: { + control: { type: 'number', min: 0, max: 5, step: 1 }, + description: 'Minimum characters before options load', + }, + minCharsMessage: { control: 'text' }, + highlightMatch: { control: 'boolean' }, disabled: { control: 'boolean' }, noOptionsMessage: { control: 'text' }, loadingMessage: { control: 'text' }, @@ -55,6 +61,24 @@ export const Default: Story = { }, }; +/** + * Search-box configuration: a search (submit) button, a 3-character minimum, + * and bolded matches — mirrors the site banner search behaviour. + */ +export const SearchBox: Story = { + args: { + id: 'fruit-search', + label: 'Search', + options: fruits, + placeholder: 'Search…', + minChars: 3, + highlightMatch: true, + searchButtonLabel: 'Search', + onChange: fn(), + onSubmit: fn(), + }, +}; + /** Asynchronous data loading with a simulated 400 ms network delay. */ export const AsyncLoadOptions: Story = { args: { @@ -118,15 +142,13 @@ export const Disabled: Story = { }; /** Controlled component — the parent manages the selected value. */ -const ControlledTemplate = (args: React.ComponentProps>) => { +const ControlledTemplate = ( + args: React.ComponentProps> +) => { const [value, setValue] = useState(null); return (
- setValue(opt)} - /> + setValue(opt)} />

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

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