diff --git a/app/vmui/packages/vmui/src/components/Configurators/QueryEditor/QueryEditor.tsx b/app/vmui/packages/vmui/src/components/Configurators/QueryEditor/QueryEditor.tsx index 5f5f63587f..53ea0a7ad4 100644 --- a/app/vmui/packages/vmui/src/components/Configurators/QueryEditor/QueryEditor.tsx +++ b/app/vmui/packages/vmui/src/components/Configurators/QueryEditor/QueryEditor.tsx @@ -1,11 +1,9 @@ -import React, { FC, useEffect, useMemo, useRef, useState } from "preact/compat"; +import React, { FC, useRef } from "preact/compat"; import { KeyboardEvent } from "react"; import { ErrorTypes } from "../../../types"; import TextField from "../../Main/TextField/TextField"; -import Popper from "../../Main/Popper/Popper"; -import useClickOutside from "../../../hooks/useClickOutside"; +import Autocomplete from "../../Main/Autocomplete/Autocomplete"; import "./style.scss"; -import classNames from "classnames"; export interface QueryEditorProps { onChange: (query: string) => void; @@ -34,25 +32,10 @@ const QueryEditor: FC = ({ disabled = false }) => { - const [focusOption, setFocusOption] = useState(-1); - const [openAutocomplete, setOpenAutocomplete] = useState(false); - const autocompleteAnchorEl = useRef(null); - const wrapperEl = useRef(null); - const foundOptions = useMemo(() => { - if (!openAutocomplete) return []; - try { - const regexp = new RegExp(String(value), "i"); - const found = options.filter((item) => regexp.test(item) && (item !== value)); - return found.sort((a,b) => (a.match(regexp)?.index || 0) - (b.match(regexp)?.index || 0)); - } catch (e) { - return []; - } - }, [openAutocomplete, options, value]); - - const handleCloseAutocomplete = () => { - setOpenAutocomplete(false); + const handleSelect = (val: string) => { + onChange(val); }; const handleKeyDown = (e: KeyboardEvent) => { @@ -62,71 +45,25 @@ const QueryEditor: FC = ({ const arrowUp = key === "ArrowUp"; const arrowDown = key === "ArrowDown"; const enter = key === "Enter"; - const escape = key === "Escape"; - const hasAutocomplete = openAutocomplete && foundOptions.length; - const valueAutocomplete = foundOptions[focusOption]; - - const isArrows = arrowUp || arrowDown; - const arrowsByOptions = isArrows && hasAutocomplete; - const arrowsByHistory = isArrows && ctrlMetaKey; - const enterByOptions = enter && hasAutocomplete; - - if (arrowsByOptions || arrowsByHistory || enterByOptions) { + // prev value from history + if (arrowUp && ctrlMetaKey) { e.preventDefault(); - } - - // ArrowUp - if (arrowUp && hasAutocomplete && !ctrlMetaKey) { - setFocusOption((prev) => prev === 0 ? 0 : prev - 1); - } else if (arrowUp && ctrlMetaKey) { onArrowUp(); } - // ArrowDown - if (arrowDown && hasAutocomplete && !ctrlMetaKey) { - setFocusOption((prev) => prev >= foundOptions.length - 1 ? foundOptions.length - 1 : prev + 1); - } else if (arrowDown && ctrlMetaKey) { + // next value from history + if (arrowDown && ctrlMetaKey) { + e.preventDefault(); onArrowDown(); } - // Enter - if (valueAutocomplete && enter && hasAutocomplete && !shiftKey && !ctrlMetaKey) { - if (disabled) return; - onChange(valueAutocomplete); - handleCloseAutocomplete(); - } else if (enter && !shiftKey) { + // execute query + if (enter && !shiftKey) { onEnter(); - handleCloseAutocomplete(); - } - - // Escape - if (escape && openAutocomplete) { - handleCloseAutocomplete(); } }; - const createHandlerOnChangeAutocomplete = (item: string) => () => { - if (disabled) return; - onChange(item); - handleCloseAutocomplete(); - }; - - useEffect(() => { - if (!autocomplete) return; - setFocusOption(-1); - const words = (value.match(/[a-zA-Z_:.][a-zA-Z0-9_:.]*/gm) || []).length; - setOpenAutocomplete(autocomplete && value.length > 2 && words <= 1); - }, [autocomplete, value]); - - useEffect(() => { - if (!wrapperEl.current) return; - const target = wrapperEl.current.childNodes[focusOption] as HTMLElement; - if (target?.scrollIntoView) target.scrollIntoView({ block: "center" }); - }, [focusOption]); - - useClickOutside(autocompleteAnchorEl, handleCloseAutocomplete, wrapperEl); - return
= ({ onChange={onChange} disabled={disabled} /> - -
- {foundOptions.map((item, i) => -
- {item} -
)} -
-
+ {autocomplete && ( + + )}
; }; diff --git a/app/vmui/packages/vmui/src/components/Main/Autocomplete/Autocomplete.tsx b/app/vmui/packages/vmui/src/components/Main/Autocomplete/Autocomplete.tsx new file mode 100644 index 0000000000..021e05797c --- /dev/null +++ b/app/vmui/packages/vmui/src/components/Main/Autocomplete/Autocomplete.tsx @@ -0,0 +1,132 @@ +import React, { FC, Ref, useEffect, useMemo, useRef, useState } from "preact/compat"; +import classNames from "classnames"; +import useClickOutside from "../../../hooks/useClickOutside"; +import Popper from "../Popper/Popper"; +import "./style.scss"; + +interface AutocompleteProps { + value: string + options: string[] + anchor: Ref + disabled?: boolean + maxWords?: number + onSelect: (val: string) => void +} + +const Autocomplete: FC = ({ + value, + options, + anchor, + disabled, + maxWords = 1, + onSelect, +}) => { + const wrapperEl = useRef(null); + + const [openAutocomplete, setOpenAutocomplete] = useState(false); + const [focusOption, setFocusOption] = useState(-1); + + const foundOptions = useMemo(() => { + if (!openAutocomplete) return []; + try { + const regexp = new RegExp(String(value), "i"); + const found = options.filter((item) => regexp.test(item) && (item !== value)); + return found.sort((a,b) => (a.match(regexp)?.index || 0) - (b.match(regexp)?.index || 0)); + } catch (e) { + return []; + } + }, [openAutocomplete, options, value]); + + const handleCloseAutocomplete = () => { + setOpenAutocomplete(false); + }; + + const createHandlerSelect = (item: string) => () => { + if (disabled) return; + onSelect(item); + handleCloseAutocomplete(); + }; + + const scrollToValue = () => { + if (!wrapperEl.current) return; + const target = wrapperEl.current.childNodes[focusOption] as HTMLElement; + if (target?.scrollIntoView) target.scrollIntoView({ block: "center" }); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + const { key, ctrlKey, metaKey, shiftKey } = e; + const modifiers = ctrlKey || metaKey || shiftKey; + + if (key === "ArrowUp" && !modifiers) { + e.preventDefault(); + setFocusOption((prev) => prev <= 0 ? 0 : prev - 1); + } + + if (key === "ArrowDown" && !modifiers) { + e.preventDefault(); + const lastIndex = foundOptions.length - 1; + setFocusOption((prev) => prev >= lastIndex ? lastIndex : prev + 1); + } + + if (key === "Enter") { + const value = foundOptions[focusOption]; + value && onSelect(value); + handleCloseAutocomplete(); + } + + if (key === "Escape") { + handleCloseAutocomplete(); + } + }; + + useEffect(() => { + const words = (value.match(/[a-zA-Z_:.][a-zA-Z0-9_:.]*/gm) || []).length; + setOpenAutocomplete(value.length > 2 && words <= maxWords); + }, [value]); + + useEffect(() => { + scrollToValue(); + + window.addEventListener("keydown", handleKeyDown); + + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [focusOption, foundOptions]); + + useEffect(() => { + setFocusOption(-1); + }, [foundOptions]); + + useClickOutside(wrapperEl, handleCloseAutocomplete); + + return ( + +
+ {foundOptions.map((option, i) => +
+ {option} +
+ )} +
+
+ ); +}; + +export default Autocomplete; diff --git a/app/vmui/packages/vmui/src/components/Main/Autocomplete/style.scss b/app/vmui/packages/vmui/src/components/Main/Autocomplete/style.scss new file mode 100644 index 0000000000..ad4d07ae67 --- /dev/null +++ b/app/vmui/packages/vmui/src/components/Main/Autocomplete/style.scss @@ -0,0 +1,6 @@ +@use "src/styles/variables" as *; + +.vm-autocomplete { + max-height: 300px; + overflow: auto; +}