diff --git a/src/form/SuggestInput.tsx b/src/form/SuggestInput.tsx index 600d04d..e268c1e 100644 --- a/src/form/SuggestInput.tsx +++ b/src/form/SuggestInput.tsx @@ -1,6 +1,8 @@ //--------------------------------------------------------------------// // Imports +//modules +import { useState } from 'react'; //frui import type { SlotStyleProp, @@ -37,12 +39,16 @@ export type SuggestInputProps = Omit void, //slot: style to apply to the select control option?: CallableSlotStyleProp, //serialized list of options as array or object options?: DropdownOptionProp + //remote url to fetch suggestions + remote?: string }, 'multiple' >; @@ -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 //serialized list of options as array or object options, //: SelectOption[]|Record + //remote url to fetch suggestions + remote, //?: string //position of the dropdown right, //?: boolean //custom inline styles @@ -159,6 +171,11 @@ export function SuggestInput(props: SuggestInputProps) { value, //?: T ...inputProps } = props; + //hooks + const [ + remoteOptions, + setRemoteOptions + ] = useState(options); //variables // determine classes const classes = [ 'frui-form-suggest-input' ]; @@ -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 ( {children} diff --git a/tests/form/SuggestInput.test.tsx b/tests/form/SuggestInput.test.tsx index 7731f05..fee4a87 100644 --- a/tests/form/SuggestInput.test.tsx +++ b/tests/form/SuggestInput.test.tsx @@ -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 @@ -40,49 +34,49 @@ vi.mock('src/helpers/getClassStyles.js', () => ({ //--------------------------------------------------------------------// // Tests -describe(' - Pick one - + ); - 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( - Click me - + /> ); - 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( - ', () => { expect(onUpdate).toHaveBeenCalledWith('no'); }); }); - - it('adds hidden input after selection', async () => { + it('shows controlled input value', async () => { const { rerender } = render( - ); 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( - ); + 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( - ', () => { /> ); await waitFor(() => { - const updated = container.querySelector( - '.frui-form-select-control-selected' - ); - expect(updated).toBeInTheDocument(); - expect(updated?.textContent).toContain('Opt1'); + expect(input.value).toBe('opt1'); }); } ); diff --git a/web/docs/views/form/suggest-input.tsx b/web/docs/views/form/suggest-input.tsx index b2c74a2..272c128 100644 --- a/web/docs/views/form/suggest-input.tsx +++ b/web/docs/views/form/suggest-input.tsx @@ -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' ] ]; @@ -90,7 +92,26 @@ return ( -` +`, +//5 +``, +//6 +`const mockFetch = vi.fn(() => + Promise.resolve({ + json: () => Promise.resolve([ + { value: 'result1', label: 'Result 1' }, + { value: 'result2', label: 'Result 2' } + ]) + } as Response) +); +` ]; //--------------------------------------------------------------------// @@ -282,6 +303,24 @@ export function Examples() { {examples[4]} + {/* Remote Example */} + + + {examples[5]} + + + {/* Custom Fetch Example */} + + + {examples[6]} + + ); };