Skip to content
33 changes: 32 additions & 1 deletion src/form/SuggestInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import type { InputProps } from './Input.js';
import getSlotStyles from '../helpers/getSlotStyles.js';
import Dropdown from '../base/Dropdown.js';
import Input from './Input.js';
//modules
import { useState } from 'react';
Comment thread
josephlouise74 marked this conversation as resolved.
Outdated

//--------------------------------------------------------------------//
// Types
Expand All @@ -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,19 @@ export function SuggestInput(props: SuggestInputProps) {
// get slot styles
const controlStyles = control ? getSlotStyles(control, {}) : {};
const dropdownStyles = dropdown ? getSlotStyles(dropdown, {}) : {};
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 +211,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 +221,7 @@ export function SuggestInput(props: SuggestInputProps) {
{...inputProps}
className={controlStyles.className}
style={controlStyles.style}
onQuery={handleQuery}
/>
</Dropdown.Control>
{children}
Expand Down
155 changes: 66 additions & 89 deletions tests/form/SuggestInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// Imports

//frui
import Select, { SelectPlaceholder } from '../../src/form/Select.js';
import SuggestInput from '../../src/form/SuggestInput.js';
//tests
import '@testing-library/jest-dom';
import {
Expand Down Expand Up @@ -40,49 +40,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 +92,7 @@ describe('<Select />', () => {
/>
);
rerender(
<Select
<SuggestInput
onUpdate={onUpdate}
options={[
{ value: 'yes', label: 'Yes' },
Expand All @@ -105,87 +105,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 +175,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
41 changes: 40 additions & 1 deletion web/docs/views/form/suggest-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,14 @@ const props = [
[ 'className', 'string', 'No', 'Standard HTML class names' ],
[ 'defaultValue', 'string', 'No', 'Alias to value' ],
[ 'error', 'string|boolean', 'No', 'Any error message' ],
[ 'fetch', 'Function', 'No', 'Custom fetch function for dependency injection (mainly for tests)' ],
[ 'name', 'string', 'No', 'Used for react server components.' ],
[ 'onChange', 'Function', 'No', 'Event handler when value has changed' ],
[ 'onDropdown', 'Function', 'No', 'Event handler when dropdown opens/closes' ],
[ 'onQuery', 'Function', 'No', 'Event handler when something is searched' ],
[ 'onUpdate', 'Function', 'No', 'Update event handler' ],
[ 'options', 'string[]', 'No', 'List of select options.' ],
[ 'remote', 'string', 'No', 'Remote URL for fetching suggestions (use {{QUERY}} as placeholder)' ],
[ 'style', 'CSS Object', 'No', 'Standard CSS object' ],
[ 'value', 'string', 'No', 'Selected value from the options' ]
];
Expand Down Expand Up @@ -90,7 +92,26 @@ return (
</div>
</div>
</SuggestInput.Option>
</SuggestInput>`
</SuggestInput>`,
//5
`<SuggestInput
remote="https://api.example.com/search?q={{QUERY}}"
placeholder="Search products..."
/>`,
//6
`const mockFetch = vi.fn(() =>
Promise.resolve({
json: () => Promise.resolve([
{ value: 'result1', label: 'Result 1' },
{ value: 'result2', label: 'Result 2' }
])
} as Response)
);
<SuggestInput
fetch={mockFetch}
remote="https://api.example.com/search?q={{QUERY}}"
placeholder="Search..."
/>`
];

//--------------------------------------------------------------------//
Expand Down Expand Up @@ -282,6 +303,24 @@ export function Examples() {
</Preview.Example>
<Preview.Code>{examples[4]}</Preview.Code>
</Preview>
{/* Remote Example */}
<Preview
title="Remote Example"
className="border border-2 theme-bc-3 px-w-50-7 rmd-px-w-100-0"
>
<Preview.Example center padding>
<Code language="typescript">{examples[5]}</Code>
</Preview.Example>
</Preview>
{/* Custom Fetch Example */}
<Preview
title="Custom Fetch for Testing"
className="border border-2 theme-bc-3 px-w-50-7 rmd-px-w-100-0"
>
<Preview.Example center padding>
<Code language="typescript">{examples[6]}</Code>
</Preview.Example>
</Preview>
</div>
);
};
Expand Down