Skip to content
34 changes: 33 additions & 1 deletion src/form/SuggestInput.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
//--------------------------------------------------------------------//
// Imports

//modules
import { useState } from 'react';
//frui
import type {
SlotStyleProp,
Expand Down Expand Up @@ -37,12 +39,16 @@ export type SuggestInputProps = Omit<InputProps
control?: SlotStyleProp,
//slot: style to apply to the select drop down
dropdown?: SlotStyleProp,
//custom fetch function for dependency injection
fetch?: typeof fetch,
//called whenever user types
onQuery?: (query: string) => void,
//slot: style to apply to the select control
option?: CallableSlotStyleProp<DropdownStates>,
//serialized list of options as array or object
options?: DropdownOptionProp
//remote url to fetch suggestions
remote?: string
},
'multiple'
>;
Expand Down Expand Up @@ -141,14 +147,20 @@ export function SuggestInput(props: SuggestInputProps) {
error, //?: boolean
//position of the dropdown
left, //?: boolean
//custom fetch function for dependency injection (mainly for tests)
fetch: customFetch = fetch,
//dropdown handler
onDropdown, //?: (show: boolean) => void
//called whenever user types
onQuery, //?: (query: string) => void
//update handler
onUpdate, //?: (value: string) => void
//slot: style to apply to the select control
option, //: CallableSlotStyleProp<SelectStates>
//serialized list of options as array or object
options, //: SelectOption[]|Record<string, string>
//remote url to fetch suggestions
remote, //?: string
//position of the dropdown
right, //?: boolean
//custom inline styles
Expand All @@ -159,6 +171,11 @@ export function SuggestInput(props: SuggestInputProps) {
value, //?: T
...inputProps
} = props;
//hooks
const [
remoteOptions,
setRemoteOptions
] = useState<DropdownOptionProp | undefined>(options);
//variables
// determine classes
const classes = [ 'frui-form-suggest-input' ];
Expand All @@ -169,6 +186,20 @@ export function SuggestInput(props: SuggestInputProps) {
// get slot styles
const controlStyles = control ? getSlotStyles(control, {}) : {};
const dropdownStyles = dropdown ? getSlotStyles(dropdown, {}) : {};
//handlers
const handleQuery = async (query: string) => {
if (typeof remote === 'string' && query) {
const response =
await customFetch(
remote.replace('{{QUERY}}', encodeURIComponent(query))
);
const data = await response.json();
if (Array.isArray(data)) {
setRemoteOptions(data);
}
}
onQuery && onQuery(query);
};
//render
return (
<Dropdown
Expand All @@ -181,7 +212,7 @@ export function SuggestInput(props: SuggestInputProps) {
onDropdown={onDropdown}
onUpdate={onUpdate}
option={option}
options={options}
options={remoteOptions}
right={right}
top={top}
value={value}
Expand All @@ -191,6 +222,7 @@ export function SuggestInput(props: SuggestInputProps) {
{...inputProps}
className={controlStyles.className}
style={controlStyles.style}
onQuery={handleQuery}
/>
</Dropdown.Control>
{children}
Expand Down
173 changes: 72 additions & 101 deletions tests/form/SuggestInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,15 @@
// Imports

//frui
import Select, { SelectPlaceholder } from '../../src/form/Select.js';
//tests
import SuggestInput from '../../src/form/SuggestInput.js';
import '@testing-library/jest-dom';
import {
describe,
expect,
it,
vi
} from 'vitest';
import {
fireEvent,
render,
screen,
waitFor
import {
fireEvent,
render,
screen,
waitFor
} from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';

//--------------------------------------------------------------------//
// Mocks
Expand All @@ -40,49 +34,49 @@ vi.mock('src/helpers/getClassStyles.js', () => ({
//--------------------------------------------------------------------//
// Tests

describe('<Select />', () => {
it('renders base wrapper and placeholder text', () => {
describe('<SuggestInput />', () => {
it('renders base wrapper and input field', () => {
render(
<Select placeholder="Pick one">
<SelectPlaceholder>Pick one</SelectPlaceholder>
</Select>
<SuggestInput placeholder="Type to search" />
);
const wrapper = document.querySelector('.frui-form-select');
const wrapper =
document.querySelector('.frui-form-suggest-input');
expect(wrapper).toBeInTheDocument();
expect(screen.getByText('Pick one')).toBeInTheDocument();
expect(
screen.getByPlaceholderText('Type to search')
).toBeInTheDocument();
});

it('applies error class when error prop set', () => {
const { container } = render(<Select error />);
const wrapper = container.querySelector('.frui-form-select');
const { container } = render(<SuggestInput error />);
const wrapper =
container.querySelector('.frui-form-suggest-input');
expect(wrapper).toBeInTheDocument();
expect(wrapper?.className).toContain('frui-form-select-error');
expect(
wrapper?.className
).toContain('frui-form-suggest-input-error');
});

it('toggles dropdown open on placeholder click', () => {
it('opens dropdown after typing minimum chars', () => {
const onQuery = vi.fn();
render(
<Select
<SuggestInput
chars={3}
onQuery={onQuery}
options={[
{ value: '1', label: 'One' },
{ value: '2', label: 'Two' }
{ value: 'apple', label: 'Apple' },
{ value: 'apricot', label: 'Apricot' }
]}
>
<SelectPlaceholder>Click me</SelectPlaceholder>
</Select>
/>
);
const toggle = screen.getByText('Click me');
fireEvent.click(toggle);
expect(
document.querySelector(
'.frui-form-select-control-actions-toggle'
)
).toBeInTheDocument();
const input = screen.getByRole('textbox') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'ap' } });
expect(onQuery).not.toHaveBeenCalled();
fireEvent.change(input, { target: { value: 'app' } });
expect(onQuery).toHaveBeenCalledWith('app');
});

it('calls onUpdate when external value changes', async () => {
const onUpdate = vi.fn();
const { rerender } = render(
<Select
<SuggestInput
onUpdate={onUpdate}
options={[
{ value: 'yes', label: 'Yes' },
Expand All @@ -92,7 +86,7 @@ describe('<Select />', () => {
/>
);
rerender(
<Select
<SuggestInput
onUpdate={onUpdate}
options={[
{ value: 'yes', label: 'Yes' },
Expand All @@ -105,87 +99,68 @@ describe('<Select />', () => {
expect(onUpdate).toHaveBeenCalledWith('no');
});
});

it('adds hidden input after selection', async () => {
it('shows controlled input value', async () => {
const { rerender } = render(
<Select
name="colors"
options={[ { value: 'blue', label: 'Blue' } ]}
<SuggestInput
name="search"
options={[ { value: 'test', label: 'Test' } ]}
value=""
/>
);
rerender(
<Select
name="colors"
options={[ { value: 'blue', label: 'Blue' } ]}
value="blue"
<SuggestInput
name="search"
options={[ { value: 'test', label: 'Test' } ]}
value="test"
/>
);
await waitFor(() => {
const hidden = document.querySelector(
'input[ type="hidden" ]'
) as HTMLInputElement;
expect(hidden).toBeInTheDocument();
expect(hidden.name).toBe('colors');
expect(hidden.value).toBe('blue');
const input = screen.getByRole('textbox') as HTMLInputElement;
expect(input).toBeInTheDocument();
expect(input.name).toBe('search');
expect(input.value).toBe('test');
});
});

it('supports multiple selections and clear button', async () => {
const { rerender } = render(
<Select
multiple
name="multi"
options={[
{ value: 'a', label: 'A' },
{ value: 'b', label: 'B' }
]}
value={[]}
/>
it('fetches remote suggestions with custom fetch prop', async () => {
const mockFetch = vi.fn(() =>
Promise.resolve({
json: () => Promise.resolve([
{ value: 'result1', label: 'Result 1' },
{ value: 'result2', label: 'Result 2' }
])
} as Response)
);
rerender(
<Select
multiple
name="multi"
options={[
{ value: 'a', label: 'A' },
{ value: 'b', label: 'B' }
]}
value={[ 'a', 'b' ]}
render(
<SuggestInput
chars={2}
fetch={mockFetch}
remote="https://api.example.com/search?q={{QUERY}}"
/>
);
const input = screen.getByRole('textbox') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'test' } });
await waitFor(() => {
const clearBtn = document.querySelector(
'.frui-form-select-control-actions-clear'
);
expect(clearBtn).toBeInTheDocument();
fireEvent.click(clearBtn!);
const inputs = document.querySelectorAll(
'input[ type="hidden" ]'
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/search?q=test'
);
expect(inputs.length).toBe(0);
});
});

it(
'renders correct selected option when value prop provided',
'renders correct selected value when value prop provided',
async () => {
const { rerender, container } = render(
<Select
const { rerender } = render(
<SuggestInput
options={[
{ value: 'opt1', label: 'Opt1' },
{ value: 'opt2', label: 'Opt2' }
]}
value="opt2"
/>
);
const control = container.querySelector(
'.frui-form-select-control-selected'
);
expect(control).toBeInTheDocument();
expect(control?.textContent).toContain('Opt2');
const input = screen.getByRole('textbox') as HTMLInputElement;
expect(input.value).toBe('opt2');
rerender(
<Select
<SuggestInput
options={[
{ value: 'opt1', label: 'Opt1' },
{ value: 'opt2', label: 'Opt2' }
Expand All @@ -194,11 +169,7 @@ describe('<Select />', () => {
/>
);
await waitFor(() => {
const updated = container.querySelector(
'.frui-form-select-control-selected'
);
expect(updated).toBeInTheDocument();
expect(updated?.textContent).toContain('Opt1');
expect(input.value).toBe('opt1');
});
}
);
Expand Down
Loading