refactor: create Autocomplete component (#3390)

This commit is contained in:
Yury Molodov 2022-11-25 16:25:35 +01:00 committed by Aliaksandr Valialkin
parent ed39d0d11c
commit 805d93dfec
No known key found for this signature in database
GPG key ID: A72BEC6CD3D0DED1
3 changed files with 157 additions and 98 deletions

View file

@ -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>;
};

View file

@ -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;

View file

@ -0,0 +1,6 @@
@use "src/styles/variables" as *;
.vm-autocomplete {
max-height: 300px;
overflow: auto;
}