From 1ab66186ca59e9b065c6dba5d05afaae4b7dc37b Mon Sep 17 00:00:00 2001
From: Yury Molodov <yurymolodov@gmail.com>
Date: Fri, 25 Nov 2022 16:25:35 +0100
Subject: [PATCH] refactor: create Autocomplete component (#3390)

---
 .../Configurators/QueryEditor/QueryEditor.tsx | 117 +++-------------
 .../Main/Autocomplete/Autocomplete.tsx        | 132 ++++++++++++++++++
 .../components/Main/Autocomplete/style.scss   |   6 +
 3 files changed, 157 insertions(+), 98 deletions(-)
 create mode 100644 app/vmui/packages/vmui/src/components/Main/Autocomplete/Autocomplete.tsx
 create mode 100644 app/vmui/packages/vmui/src/components/Main/Autocomplete/style.scss

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<QueryEditorProps> = ({
   disabled = false
 }) => {
 
-  const [focusOption, setFocusOption] = useState(-1);
-  const [openAutocomplete, setOpenAutocomplete] = useState(false);
-
   const autocompleteAnchorEl = useRef<HTMLDivElement>(null);
-  const wrapperEl = useRef<HTMLDivElement>(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<QueryEditorProps> = ({
     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 <div
     className="vm-query-editor"
     ref={autocompleteAnchorEl}
@@ -141,30 +78,14 @@ const QueryEditor: FC<QueryEditorProps> = ({
       onChange={onChange}
       disabled={disabled}
     />
-    <Popper
-      open={openAutocomplete}
-      buttonRef={autocompleteAnchorEl}
-      placement="bottom-left"
-      onClose={handleCloseAutocomplete}
-    >
-      <div
-        className="vm-query-editor-autocomplete"
-        ref={wrapperEl}
-      >
-        {foundOptions.map((item, i) =>
-          <div
-            className={classNames({
-              "vm-list__item": true,
-              "vm-list__item_active": i === focusOption
-            })}
-            id={`$autocomplete$${item}`}
-            key={item}
-            onClick={createHandlerOnChangeAutocomplete(item)}
-          >
-            {item}
-          </div>)}
-      </div>
-    </Popper>
+    {autocomplete && (
+      <Autocomplete
+        value={value}
+        options={options}
+        anchor={autocompleteAnchorEl}
+        onSelect={handleSelect}
+      />
+    )}
   </div>;
 };
 
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<HTMLElement>
+  disabled?: boolean
+  maxWords?: number
+  onSelect: (val: string) => void
+}
+
+const Autocomplete: FC<AutocompleteProps> = ({
+  value,
+  options,
+  anchor,
+  disabled,
+  maxWords = 1,
+  onSelect,
+}) => {
+  const wrapperEl = useRef<HTMLDivElement>(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 (
+    <Popper
+      open={openAutocomplete}
+      buttonRef={anchor}
+      placement="bottom-left"
+      onClose={handleCloseAutocomplete}
+    >
+      <div
+        className="vm-autocomplete"
+        ref={wrapperEl}
+      >
+        {foundOptions.map((option, i) =>
+          <div
+            className={classNames({
+              "vm-list__item": true,
+              "vm-list__item_active": i === focusOption
+            })}
+            id={`$autocomplete$${option}`}
+            key={option}
+            onClick={createHandlerSelect(option)}
+          >
+            {option}
+          </div>
+        )}
+      </div>
+    </Popper>
+  );
+};
+
+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;
+}