diff --git a/src/form/KaotoForm.scss b/src/form/KaotoForm.scss index 60eda3b..c5bd671 100644 --- a/src/form/KaotoForm.scss +++ b/src/form/KaotoForm.scss @@ -42,6 +42,22 @@ justify-content: space-between; } + &__suggestions-button { + display: none; + } + + /* stylelint-disable-next-line selector-class-pattern */ + .pf-v6-c-text-input-group:hover &__suggestions-button, + /* stylelint-disable-next-line selector-class-pattern */ + .pf-v6-c-text-input-group:focus-within &__suggestions-button, + /* stylelint-disable-next-line selector-class-pattern */ + .pf-v6-c-input-group:hover &__suggestions-button, + /* stylelint-disable-next-line selector-class-pattern */ + .pf-v6-c-input-group:focus-within &__suggestions-button, + &__suggestions-button:focus { + display: inline-flex; + } + &__empty { display: none; } diff --git a/src/form/KeyValue/KeyValueField.test.tsx b/src/form/KeyValue/KeyValueField.test.tsx index 2977978..c4f8d77 100644 --- a/src/form/KeyValue/KeyValueField.test.tsx +++ b/src/form/KeyValue/KeyValueField.test.tsx @@ -3,7 +3,10 @@ import { KeyValueField } from './KeyValueField'; // Mock useSuggestions to control its output jest.mock('../hooks/suggestions', () => ({ - useSuggestions: jest.fn(() => null), + useSuggestions: jest.fn(() => ({ + suggestionsMenu: null, + openSuggestions: jest.fn(), + })), })); describe('KeyValueField', () => { @@ -75,7 +78,10 @@ describe('KeyValueField', () => { const SuggestionsMenu = () =>
Suggestions
; const { useSuggestions } = jest.requireMock('../hooks/suggestions'); - useSuggestions.mockImplementation(() => ); + useSuggestions.mockImplementation(() => ({ + suggestionsMenu: , + openSuggestions: jest.fn(), + })); const { getByTestId } = render(); const input = getByTestId('keyvalue-input'); diff --git a/src/form/KeyValue/KeyValueField.tsx b/src/form/KeyValue/KeyValueField.tsx index f1a2aab..b36cbe6 100644 --- a/src/form/KeyValue/KeyValueField.tsx +++ b/src/form/KeyValue/KeyValueField.tsx @@ -22,7 +22,7 @@ export const KeyValueField = forwardRef( useImperativeHandle(ref, () => inputRef.current as HTMLInputElement); - const suggestions = useSuggestions({ + const { suggestionsMenu } = useSuggestions({ propName: name, schema: STRING_SCHEMA, inputRef, @@ -47,7 +47,7 @@ export const KeyValueField = forwardRef( value={value} /> - {suggestions} + {suggestionsMenu} ); }, diff --git a/src/form/__snapshots__/KaotoForm.test.tsx.snap b/src/form/__snapshots__/KaotoForm.test.tsx.snap index d3872ef..6a5abf0 100644 --- a/src/form/__snapshots__/KaotoForm.test.tsx.snap +++ b/src/form/__snapshots__/KaotoForm.test.tsx.snap @@ -47,7 +47,7 @@ exports[`KaotoForm should validate the model 1`] = ` + @@ -47,7 +62,7 @@ describe('useSuggestions', () => { const renderWithContext = (children: ReactNode) => { return render( - {children}, + {children}, ); }; @@ -75,35 +90,29 @@ describe('useSuggestions', () => { const inputElement = document.createElement('input'); const inputRef = { current: inputElement }; - let result: ReactNode; - - await act(async () => { - const hookResult = renderHook( - () => - useSuggestions({ - inputRef, - propName: 'testProp', - schema: { type: 'string' }, - value: '', - setValue: setValueMock, - }), - { - wrapper: ({ children }) => ( - - {children} - - ), - }, - ); - - result = hookResult.result.current; - }); + const { result } = renderHook( + () => + useSuggestions({ + inputRef, + propName: 'testProp', + schema: { type: 'string' }, + value: '', + setValue: setValueMock, + }), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); await waitFor(() => { expect(mockProvider.getSuggestions).not.toHaveBeenCalled(); }); - expect(result).toBeDefined(); + expect(result.current.suggestionsMenu).toBeDefined(); }); it('should handle setValue being undefined', () => { @@ -116,7 +125,7 @@ describe('useSuggestions', () => { const { result } = renderHook(() => useSuggestions(propsWithoutSetValue), { wrapper: ({ children }) => ( - {children} + {children} ), }); @@ -139,7 +148,7 @@ describe('useSuggestions', () => { }), { wrapper: ({ children }) => ( - + {children} ), @@ -175,7 +184,7 @@ describe('useSuggestions', () => { }), { wrapper: ({ children }) => ( - + {children} ), @@ -205,7 +214,7 @@ describe('useSuggestions', () => { const { rerender } = renderHook(() => useSuggestions(props), { wrapper: ({ children }) => ( - {children} + {children} ), }); diff --git a/src/form/hooks/suggestions.tsx b/src/form/hooks/suggestions.tsx index 1f1829f..b441539 100644 --- a/src/form/hooks/suggestions.tsx +++ b/src/form/hooks/suggestions.tsx @@ -1,6 +1,6 @@ import { Menu, MenuContent, MenuItem, MenuList, Popper, SearchInput } from '@patternfly/react-core'; import { JSONSchema4 } from 'json-schema'; -import { ReactNode, RefObject, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { ReactNode, RefObject, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from 'react'; import { GroupedSuggestions, Suggestion, SuggestionProvider } from '../models/suggestions'; import { SuggestionContext } from '../providers'; import { applySuggestion } from '../utils/apply-suggestion'; @@ -13,14 +13,28 @@ type UseSuggestionsProps = { value: string | number; setValue?: (value: string) => void; }; -export const useSuggestions = ({ propName, schema, inputRef, value, setValue }: UseSuggestionsProps): ReactNode => { - const [isVisible, setIsVisible] = useState(false); + +type UseSuggestionsReturn = { + suggestionsMenu: ReactNode; + openSuggestions: () => void; +}; + +export const useSuggestions = ({ + propName, + schema, + inputRef, + value, + setValue, +}: UseSuggestionsProps): UseSuggestionsReturn => { + const menuId = `${propName}-${useId()}`; const [searchValue, setSearchValue] = useState(''); const [groupedSuggestions, setGroupedSuggestions] = useState({ root: [] }); const menuRef = useRef(null); const firstElementRef = useRef(null); const searchInputRef = useRef(null); - const { getProviders } = useContext(SuggestionContext); + const { getProviders, currentOpenMenu, setCurrentOpenMenu } = useContext(SuggestionContext); + + const isVisible = currentOpenMenu === menuId; const suggestionProviders: SuggestionProvider[] = useMemo( () => getProviders(propName, schema), @@ -30,22 +44,22 @@ export const useSuggestions = ({ propName, schema, inputRef, value, setValue }: const onEscapeKey = useCallback( (event: React.KeyboardEvent | KeyboardEvent) => { event.preventDefault(); - setIsVisible(false); + setCurrentOpenMenu(null); requestAnimationFrame(() => { inputRef.current?.focus(); }); }, - [inputRef], + [inputRef, setCurrentOpenMenu], ); const handleInputKeyDown = useCallback( (event: Event) => { if (!(event instanceof KeyboardEvent)) return; -if ((event.ctrlKey && event.code === 'Space') || (event.altKey && event.code === 'Escape')) { + if ((event.ctrlKey && event.code === 'Space') || (event.altKey && event.code === 'Escape')) { event.preventDefault(); setSearchValue(''); - setIsVisible(true); + setCurrentOpenMenu(menuId); requestAnimationFrame(() => { firstElementRef.current?.focus(); firstElementRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }); @@ -54,20 +68,20 @@ if ((event.ctrlKey && event.code === 'Space') || (event.altKey && event.code === onEscapeKey(event); } }, - [onEscapeKey], + [onEscapeKey, setCurrentOpenMenu, menuId], ); const getHandleOnClick = useCallback( (inputValue: string | number, suggestion: Suggestion) => () => { const { newValue, cursorPosition } = applySuggestion(suggestion, inputValue, inputRef.current?.selectionStart); - setIsVisible(false); + setCurrentOpenMenu(null); setValue?.(newValue); inputRef.current?.focus(); inputRef.current?.setSelectionRange(cursorPosition, cursorPosition); }, - [inputRef, setValue], + [inputRef, setValue, setCurrentOpenMenu], ); const getHandleMenuKeyDown = useCallback( @@ -129,7 +143,32 @@ if ((event.ctrlKey && event.code === 'Space') || (event.altKey && event.code === }; }, [suggestionProviders, value, propName, inputRef, isVisible, searchValue]); - /** Register keyboard bindings */ + const focusOnSearchInput = useCallback(() => { + searchInputRef.current?.focus(); + }, []); + + const handleOnSearchChange = useCallback((_event: unknown, value: string) => { + setSearchValue(value); + }, []); + + const openSuggestions = useCallback(() => { + const input = inputRef.current; + if (input?.disabled || input?.readOnly) return; + + setSearchValue(''); + setCurrentOpenMenu((prev) => { + const newValue = prev === menuId ? null : menuId; + if (newValue) { + requestAnimationFrame(() => { + firstElementRef.current?.focus(); + firstElementRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }); + }); + } + return newValue; + }); + }, [menuId, setCurrentOpenMenu, inputRef]); + + /** Register keyboard bindings and double-click handler */ useEffect(() => { const input = inputRef.current; if (!input) return; @@ -143,6 +182,7 @@ if ((event.ctrlKey && event.code === 'Space') || (event.altKey && event.code === input.addEventListener('focus', handleFocus); input.addEventListener('blur', handleBlur); + input.addEventListener('dblclick', openSuggestions); // If already focused, register immediately if (document.activeElement === input) { @@ -153,18 +193,11 @@ if ((event.ctrlKey && event.code === 'Space') || (event.altKey && event.code === input.removeEventListener('focus', handleFocus); input.removeEventListener('blur', handleBlur); input.removeEventListener('keydown', handleInputKeyDown); + input.removeEventListener('dblclick', openSuggestions); }; - }, [handleInputKeyDown, inputRef]); - - const focusOnSearchInput = useCallback(() => { - searchInputRef.current?.focus(); - }, []); + }, [handleInputKeyDown, inputRef, openSuggestions]); - const handleOnSearchChange = useCallback((_event: unknown, value: string) => { - setSearchValue(value); - }, []); - - return ( + const suggestionsMenu = ( ); + + return { + suggestionsMenu, + openSuggestions, + }; }; diff --git a/src/form/providers/SuggestionRegistryProvider.tsx b/src/form/providers/SuggestionRegistryProvider.tsx index 2692f2a..c2d7d3d 100644 --- a/src/form/providers/SuggestionRegistryProvider.tsx +++ b/src/form/providers/SuggestionRegistryProvider.tsx @@ -6,17 +6,24 @@ interface SuggestionContextApi { registerProvider: (provider: SuggestionProvider) => void; unregisterProvider: (id: string) => void; } +type MenuStateSetter = (menuId: string | null | ((prev: string | null) => string | null)) => void; + interface SuggestionContext { getProviders: (propertyName: string, schema: JSONSchema4) => SuggestionProvider[]; + currentOpenMenu: string | null; + setCurrentOpenMenu: MenuStateSetter; } export const SuggestionContextApi = createContext(undefined); export const SuggestionContext = createContext({ getProviders: () => [], + currentOpenMenu: null, + setCurrentOpenMenu: () => {}, }); export const SuggestionRegistryProvider: FunctionComponent = ({ children }) => { const [providers, setProviders] = useState([]); + const [currentOpenMenu, setCurrentOpenMenu] = useState(null); const registerProvider = useCallback((provider: SuggestionProvider) => { setProviders((prevProviders) => { @@ -48,9 +55,18 @@ export const SuggestionRegistryProvider: FunctionComponent = [registerProvider, unregisterProvider], ); + const contextValue = useMemo( + () => ({ + getProviders, + currentOpenMenu, + setCurrentOpenMenu, + }), + [getProviders, currentOpenMenu], + ); + return ( - {children} + {children} ); };