2025-04-01 10:38:02 +09:00

241 lines
7.3 KiB
TypeScript

import { css } from '@emotion/css';
import { autoUpdate, flip, shift, useFloating } from '@floating-ui/react';
import { FormEvent, useCallback, useEffect, useRef, useState } from 'react';
import * as React from 'react';
import { GrafanaTheme2, VariableSuggestion } from '@grafana/data';
import { FieldValidationMessage, Portal, ScrollContainer, TextArea, useTheme2 } from '@grafana/ui';
import { DataLinkSuggestions } from '@grafana/ui/src/components/DataLinks/DataLinkSuggestions';
import { Input } from '@grafana/ui/src/components/Input/Input';
const modulo = (a: number, n: number) => a - n * Math.floor(a / n);
const ERROR_TOOLTIP_OFFSET = 8;
export enum HTMLElementType {
InputElement = 'input',
TextAreaElement = 'textarea',
}
interface SuggestionsInputProps {
value?: string | number;
onChange: (url: string, callback?: () => void) => void;
suggestions: VariableSuggestion[];
placeholder?: string;
invalid?: boolean;
error?: string;
width?: number;
type?: HTMLElementType;
style?: React.CSSProperties;
autoFocus?: boolean;
}
const getStyles = (theme: GrafanaTheme2, inputHeight: number) => {
return {
suggestionsWrapper: css({
boxShadow: theme.shadows.z2,
}),
errorTooltip: css({
position: 'absolute',
top: inputHeight + ERROR_TOOLTIP_OFFSET + 'px',
zIndex: theme.zIndex.tooltip,
}),
inputWrapper: css({
position: 'relative',
}),
// Wrapper with child selector needed.
// When classnames are applied to the same element as the wrapper, it causes the suggestions to stop working
};
};
export const SuggestionsInput = ({
value = '',
onChange,
suggestions,
placeholder,
error,
invalid,
type = HTMLElementType.InputElement,
style,
autoFocus = false,
}: SuggestionsInputProps) => {
const [showingSuggestions, setShowingSuggestions] = useState(false);
const [suggestionsIndex, setSuggestionsIndex] = useState(0);
const [variableValue, setVariableValue] = useState<string>(value.toString());
const scrollRef = useRef<HTMLDivElement>(null);
const [scrollTop, setScrollTop] = useState(0);
const [inputHeight, setInputHeight] = useState<number>(0);
const [startPos, setStartPos] = useState<number>(0);
const theme = useTheme2();
const styles = getStyles(theme, inputHeight);
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>();
useEffect(() => {
scrollRef.current?.scrollTo(0, scrollTop);
}, [scrollTop]);
// the order of middleware is important!
const middleware = [
flip({
fallbackAxisSideDirection: 'start',
// see https://floating-ui.com/docs/flip#combining-with-shift
crossAxis: false,
boundary: document.body,
}),
shift(),
];
const { refs, floatingStyles } = useFloating({
open: showingSuggestions,
placement: 'bottom-start',
onOpenChange: setShowingSuggestions,
middleware,
whileElementsMounted: autoUpdate,
strategy: 'fixed',
});
const handleRef = useCallback(
(ref: HTMLInputElement | HTMLTextAreaElement) => {
refs.setReference(ref);
inputRef.current = ref;
},
[refs]
);
// Used to get the height of the suggestion elements in order to scroll to them.
const activeRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setScrollTop(getElementPosition(activeRef.current, suggestionsIndex));
}, [suggestionsIndex]);
const onVariableSelect = React.useCallback(
(item: VariableSuggestion, input = inputRef.current!) => {
const curPos = input.selectionStart!;
const x = input.value;
if (x[startPos - 1] === '$') {
input.value = x.slice(0, startPos) + item.value + x.slice(curPos);
} else {
input.value = x.slice(0, startPos) + '$' + `{${item.value}}` + x.slice(curPos);
}
setVariableValue(input.value);
setShowingSuggestions(false);
setSuggestionsIndex(0);
onChange(input.value);
},
[onChange, startPos]
);
const onKeyDown = React.useCallback(
(event: React.KeyboardEvent) => {
if (!showingSuggestions) {
if (event.key === '$' || (event.key === ' ' && event.ctrlKey)) {
setStartPos(inputRef.current!.selectionStart || 0);
setShowingSuggestions(true);
return;
}
return;
}
switch (event.key) {
case 'Backspace':
case 'Escape':
case 'ArrowLeft':
case 'ArrowRight':
setShowingSuggestions(false);
return setSuggestionsIndex(0);
case 'Enter':
event.preventDefault();
return onVariableSelect(suggestions[suggestionsIndex]);
case 'ArrowDown':
case 'ArrowUp':
event.preventDefault();
const direction = event.key === 'ArrowDown' ? 1 : -1;
return setSuggestionsIndex((index) => modulo(index + direction, suggestions.length));
default:
return;
}
},
[showingSuggestions, suggestions, suggestionsIndex, onVariableSelect]
);
const onValueChanged = React.useCallback((event: FormEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setVariableValue(event.currentTarget.value);
}, []);
const onBlur = React.useCallback(
(event: FormEvent<HTMLInputElement | HTMLTextAreaElement>) => {
onChange(event.currentTarget.value);
},
[onChange]
);
useEffect(() => {
setInputHeight(inputRef.current!.clientHeight);
}, []);
const inputProps = {
placeholder,
invalid,
value: variableValue,
onChange: onValueChanged,
onBlur: onBlur,
onKeyDown: onKeyDown,
};
return (
<div className={styles.inputWrapper} style={style ?? {}}>
{showingSuggestions && (
<Portal>
<div ref={refs.setFloating} style={floatingStyles} className={styles.suggestionsWrapper}>
<ScrollContainer
maxHeight="300px"
onScroll={(event) => setScrollTop(event.currentTarget.scrollTop ?? 0)}
ref={scrollRef}
>
{/* This suggestion component has a specialized name,
but is rather generalistic in implementation,
so we're using it in transformations also.
We should probably rename this to something more general. */}
<DataLinkSuggestions
activeRef={activeRef}
suggestions={suggestions}
onSuggestionSelect={onVariableSelect}
onClose={() => setShowingSuggestions(false)}
activeIndex={suggestionsIndex}
/>
</ScrollContainer>
</div>
</Portal>
)}
{invalid && error && (
<div className={styles.errorTooltip}>
<FieldValidationMessage>{error}</FieldValidationMessage>
</div>
)}
{type === HTMLElementType.InputElement ? (
<Input {...inputProps} ref={handleRef as unknown as React.RefObject<HTMLInputElement>} autoFocus={autoFocus} />
) : (
<TextArea
{...inputProps}
ref={handleRef as unknown as React.RefObject<HTMLTextAreaElement>}
autoFocus={autoFocus}
rows={5}
/>
)}
</div>
);
};
SuggestionsInput.displayName = 'SuggestionsInput';
function getElementPosition(suggestionElement: HTMLElement | null, activeIndex: number) {
return (suggestionElement?.clientHeight ?? 0) * activeIndex;
}