mirror of
https://github.com/VictoriaMetrics/VictoriaMetrics.git
synced 2025-03-11 15:34:56 +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 { KeyboardEvent } from "react";
|
||||||
import { ErrorTypes } from "../../../types";
|
import { ErrorTypes } from "../../../types";
|
||||||
import TextField from "../../Main/TextField/TextField";
|
import TextField from "../../Main/TextField/TextField";
|
||||||
import Popper from "../../Main/Popper/Popper";
|
import Autocomplete from "../../Main/Autocomplete/Autocomplete";
|
||||||
import useClickOutside from "../../../hooks/useClickOutside";
|
|
||||||
import "./style.scss";
|
import "./style.scss";
|
||||||
import classNames from "classnames";
|
|
||||||
|
|
||||||
export interface QueryEditorProps {
|
export interface QueryEditorProps {
|
||||||
onChange: (query: string) => void;
|
onChange: (query: string) => void;
|
||||||
|
@ -34,25 +32,10 @@ const QueryEditor: FC<QueryEditorProps> = ({
|
||||||
disabled = false
|
disabled = false
|
||||||
}) => {
|
}) => {
|
||||||
|
|
||||||
const [focusOption, setFocusOption] = useState(-1);
|
|
||||||
const [openAutocomplete, setOpenAutocomplete] = useState(false);
|
|
||||||
|
|
||||||
const autocompleteAnchorEl = useRef<HTMLDivElement>(null);
|
const autocompleteAnchorEl = useRef<HTMLDivElement>(null);
|
||||||
const wrapperEl = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const foundOptions = useMemo(() => {
|
const handleSelect = (val: string) => {
|
||||||
if (!openAutocomplete) return [];
|
onChange(val);
|
||||||
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 handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
@ -62,71 +45,25 @@ const QueryEditor: FC<QueryEditorProps> = ({
|
||||||
const arrowUp = key === "ArrowUp";
|
const arrowUp = key === "ArrowUp";
|
||||||
const arrowDown = key === "ArrowDown";
|
const arrowDown = key === "ArrowDown";
|
||||||
const enter = key === "Enter";
|
const enter = key === "Enter";
|
||||||
const escape = key === "Escape";
|
|
||||||
|
|
||||||
const hasAutocomplete = openAutocomplete && foundOptions.length;
|
// prev value from history
|
||||||
const valueAutocomplete = foundOptions[focusOption];
|
if (arrowUp && ctrlMetaKey) {
|
||||||
|
|
||||||
const isArrows = arrowUp || arrowDown;
|
|
||||||
const arrowsByOptions = isArrows && hasAutocomplete;
|
|
||||||
const arrowsByHistory = isArrows && ctrlMetaKey;
|
|
||||||
const enterByOptions = enter && hasAutocomplete;
|
|
||||||
|
|
||||||
if (arrowsByOptions || arrowsByHistory || enterByOptions) {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
|
||||||
|
|
||||||
// ArrowUp
|
|
||||||
if (arrowUp && hasAutocomplete && !ctrlMetaKey) {
|
|
||||||
setFocusOption((prev) => prev === 0 ? 0 : prev - 1);
|
|
||||||
} else if (arrowUp && ctrlMetaKey) {
|
|
||||||
onArrowUp();
|
onArrowUp();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArrowDown
|
// next value from history
|
||||||
if (arrowDown && hasAutocomplete && !ctrlMetaKey) {
|
if (arrowDown && ctrlMetaKey) {
|
||||||
setFocusOption((prev) => prev >= foundOptions.length - 1 ? foundOptions.length - 1 : prev + 1);
|
e.preventDefault();
|
||||||
} else if (arrowDown && ctrlMetaKey) {
|
|
||||||
onArrowDown();
|
onArrowDown();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enter
|
// execute query
|
||||||
if (valueAutocomplete && enter && hasAutocomplete && !shiftKey && !ctrlMetaKey) {
|
if (enter && !shiftKey) {
|
||||||
if (disabled) return;
|
|
||||||
onChange(valueAutocomplete);
|
|
||||||
handleCloseAutocomplete();
|
|
||||||
} else if (enter && !shiftKey) {
|
|
||||||
onEnter();
|
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
|
return <div
|
||||||
className="vm-query-editor"
|
className="vm-query-editor"
|
||||||
ref={autocompleteAnchorEl}
|
ref={autocompleteAnchorEl}
|
||||||
|
@ -141,30 +78,14 @@ const QueryEditor: FC<QueryEditorProps> = ({
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
<Popper
|
{autocomplete && (
|
||||||
open={openAutocomplete}
|
<Autocomplete
|
||||||
buttonRef={autocompleteAnchorEl}
|
value={value}
|
||||||
placement="bottom-left"
|
options={options}
|
||||||
onClose={handleCloseAutocomplete}
|
anchor={autocompleteAnchorEl}
|
||||||
>
|
onSelect={handleSelect}
|
||||||
<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>
|
|
||||||
</div>;
|
</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