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(value.toString()); const scrollRef = useRef(null); const [scrollTop, setScrollTop] = useState(0); const [inputHeight, setInputHeight] = useState(0); const [startPos, setStartPos] = useState(0); const theme = useTheme2(); const styles = getStyles(theme, inputHeight); const inputRef = useRef(); 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(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) => { setVariableValue(event.currentTarget.value); }, []); const onBlur = React.useCallback( (event: FormEvent) => { onChange(event.currentTarget.value); }, [onChange] ); useEffect(() => { setInputHeight(inputRef.current!.clientHeight); }, []); const inputProps = { placeholder, invalid, value: variableValue, onChange: onValueChanged, onBlur: onBlur, onKeyDown: onKeyDown, }; return (
{showingSuggestions && (
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. */} setShowingSuggestions(false)} activeIndex={suggestionsIndex} />
)} {invalid && error && (
{error}
)} {type === HTMLElementType.InputElement ? ( } autoFocus={autoFocus} /> ) : (