mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Slider: Enforce numeric constraints and styling within the text input (#50905)
* Add NumberInput to core slider * Change opacity interpretation To be consistent with other layers, and CSS, opacity of 0 is fully transparent and an opacity of 1 is fully opaque. * Add state management for slider. * Ensure number input step matches slider * Style input width based on expected digits * Improve styling for number input validation error
This commit is contained in:
parent
1976edaffc
commit
6dc119ef56
@ -126,7 +126,12 @@ export class NumberInput extends PureComponent<Props, State> {
|
||||
range = `> ${max}`;
|
||||
}
|
||||
return (
|
||||
<Field invalid={inputCorrected} error={`Value out of range ${range} `}>
|
||||
<Field
|
||||
invalid={inputCorrected}
|
||||
error={`Out of range ${range}`}
|
||||
validationMessageHorizontalOverflow={true}
|
||||
style={{ direction: 'rtl' }}
|
||||
>
|
||||
{this.renderInput()}
|
||||
</Field>
|
||||
);
|
||||
|
@ -1,26 +1,144 @@
|
||||
import React from 'react';
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { Global } from '@emotion/react';
|
||||
import SliderComponent from 'rc-slider';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { FieldConfigEditorProps, SliderFieldConfigSettings } from '@grafana/data';
|
||||
import { Slider } from '@grafana/ui';
|
||||
import { FieldConfigEditorProps, GrafanaTheme2, SliderFieldConfigSettings } from '@grafana/data';
|
||||
import { useTheme2 } from '@grafana/ui';
|
||||
import { getStyles } from '@grafana/ui/src/components/Slider/styles';
|
||||
|
||||
import { NumberInput } from './NumberInput';
|
||||
|
||||
export const SliderValueEditor: React.FC<FieldConfigEditorProps<number, SliderFieldConfigSettings>> = ({
|
||||
value,
|
||||
onChange,
|
||||
item,
|
||||
}) => {
|
||||
// Input reference
|
||||
const inputRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
// Settings
|
||||
const { settings } = item;
|
||||
const initialValue = typeof value === 'number' ? value : typeof value === 'string' ? +value : 0;
|
||||
const min = settings?.min || 0;
|
||||
const max = settings?.max || 100;
|
||||
const step = settings?.step;
|
||||
const marks = settings?.marks || { [min]: min, [max]: max };
|
||||
const included = settings?.included;
|
||||
const ariaLabelForHandle = settings?.ariaLabelForHandle;
|
||||
|
||||
// Core slider specific parameters and state
|
||||
const inputWidthDefault = 75;
|
||||
const isHorizontal = true;
|
||||
const theme = useTheme2();
|
||||
const SliderWithTooltip = SliderComponent;
|
||||
const [sliderValue, setSliderValue] = useState<number>(value ?? min);
|
||||
const [inputWidth, setInputWidth] = useState<number>(inputWidthDefault);
|
||||
|
||||
// Check for a difference between prop value and internal state
|
||||
useEffect(() => {
|
||||
if (value != null && value !== sliderValue) {
|
||||
setSliderValue(value);
|
||||
}
|
||||
}, [value, sliderValue]);
|
||||
|
||||
// Using input font and expected maximum number of digits, set input width
|
||||
useEffect(() => {
|
||||
const inputElement = getComputedStyle(inputRef.current!);
|
||||
const fontWeight = inputElement.getPropertyValue('font-weight') || 'normal';
|
||||
const fontSize = inputElement.getPropertyValue('font-size') || '16px';
|
||||
const fontFamily = inputElement.getPropertyValue('font-family') || 'Arial';
|
||||
const wideNumericalCharacter = '0';
|
||||
const marginDigits = 4; // extra digits to account for things like negative, exponential, and controls
|
||||
const inputPadding = 8; // TODO: base this on input styling
|
||||
const maxDigits =
|
||||
Math.max((max + (step || 0)).toString().length, (max - (step || 0)).toString().length) + marginDigits;
|
||||
const refString = wideNumericalCharacter.repeat(maxDigits);
|
||||
const calculatedTextWidth = getTextWidth(refString, `${fontWeight} ${fontSize} ${fontFamily}`);
|
||||
if (calculatedTextWidth) {
|
||||
setInputWidth(calculatedTextWidth + inputPadding * 2);
|
||||
}
|
||||
}, [max, step]);
|
||||
|
||||
const onSliderChange = useCallback(
|
||||
(v: number) => {
|
||||
setSliderValue(v);
|
||||
|
||||
if (onChange) {
|
||||
onChange(v);
|
||||
}
|
||||
},
|
||||
[setSliderValue, onChange]
|
||||
);
|
||||
|
||||
const onSliderInputChange = useCallback(
|
||||
(value?: number) => {
|
||||
let v = value;
|
||||
|
||||
if (Number.isNaN(v) || !v) {
|
||||
v = 0;
|
||||
}
|
||||
|
||||
setSliderValue(v);
|
||||
|
||||
if (onChange) {
|
||||
onChange(v);
|
||||
}
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
// Styles
|
||||
const styles = getStyles(theme, isHorizontal, Boolean(marks));
|
||||
const stylesSlider = getStylesSlider(theme, inputWidth);
|
||||
const sliderInputClassNames = !isHorizontal ? [styles.sliderInputVertical] : [];
|
||||
|
||||
return (
|
||||
<Slider
|
||||
value={initialValue}
|
||||
min={settings?.min || 0}
|
||||
max={settings?.max || 100}
|
||||
step={settings?.step}
|
||||
marks={settings?.marks}
|
||||
included={settings?.included}
|
||||
onChange={onChange}
|
||||
ariaLabelForHandle={settings?.ariaLabelForHandle}
|
||||
/>
|
||||
<div className={cx(styles.container, styles.slider)}>
|
||||
{/** Slider tooltip's parent component is body and therefore we need Global component to do css overrides for it. */}
|
||||
<Global styles={styles.slider} />
|
||||
<label className={cx(styles.sliderInput, ...sliderInputClassNames)}>
|
||||
<SliderWithTooltip
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
defaultValue={value}
|
||||
value={sliderValue}
|
||||
onChange={onSliderChange}
|
||||
vertical={!isHorizontal}
|
||||
reverse={false}
|
||||
ariaLabelForHandle={ariaLabelForHandle}
|
||||
marks={marks}
|
||||
included={included}
|
||||
/>
|
||||
<span className={stylesSlider.numberInputWrapper} ref={inputRef}>
|
||||
<NumberInput value={sliderValue} onChange={onSliderInputChange} max={max} min={min} step={step} />
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Calculate width of string with given font
|
||||
function getTextWidth(text: string, font: string): number | null {
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (context) {
|
||||
context.font = font;
|
||||
const metrics = context.measureText(text);
|
||||
return metrics.width;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const getStylesSlider = (theme: GrafanaTheme2, width: number) => {
|
||||
return {
|
||||
numberInputWrapper: css`
|
||||
margin-left: 10px;
|
||||
max-height: 32px;
|
||||
max-width: ${width}px;
|
||||
min-width: ${width}px;
|
||||
overflow: visible;
|
||||
width: 100%;
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
@ -515,7 +515,7 @@ export class GeomapPanel extends Component<Props, State> {
|
||||
const handler = await item.create(map, options, this.props.eventBus, config.theme2);
|
||||
const layer = handler.init();
|
||||
if (options.opacity != null) {
|
||||
layer.setOpacity(1 - options.opacity);
|
||||
layer.setOpacity(options.opacity);
|
||||
}
|
||||
|
||||
if (!options.name) {
|
||||
|
Loading…
Reference in New Issue
Block a user