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:
Drew Slobodnjak 2022-07-07 11:57:03 -07:00 committed by GitHub
parent 1976edaffc
commit 6dc119ef56
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 139 additions and 16 deletions

View File

@ -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>
);

View File

@ -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%;
`,
};
};

View File

@ -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) {