mirror of
https://github.com/VictoriaMetrics/VictoriaMetrics.git
synced 2025-01-10 15:14:09 +00:00
refactor: create Autocomplete component (#3390)
This commit is contained in:
parent
ed39d0d11c
commit
805d93dfec
3 changed files with 157 additions and 98 deletions
|
@ -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>;
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
|
@ -0,0 +1,6 @@
|
|||
@use "src/styles/variables" as *;
|
||||
|
||||
.vm-autocomplete {
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
}
|
Loading…
Reference in a new issue