diff --git a/package-lock.json b/package-lock.json index b5cdf92..00d99ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22633,13 +22633,15 @@ }, "packages/components": { "name": "@volkovlabs/components", - "version": "1.6.1", + "version": "1.8.0", "license": "Apache-2.0", "dependencies": { "@emotion/css": "^11.11.2", "@grafana/data": "^10.2.1", "@grafana/ui": "^10.2.1", - "rc-slider": "^10.5.0" + "classnames": "^2.5.1", + "rc-slider": "^10.5.0", + "rc-tooltip": "^6.2.0" }, "devDependencies": { "@rollup/plugin-terser": "^0.4.4", @@ -23042,6 +23044,31 @@ "node": ">=12" } }, + "packages/components/node_modules/@rc-component/trigger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-2.0.0.tgz", + "integrity": "sha512-niwKADPdY5dhdIblV6uwSayVivwo2uUISfJqri+/ovYQcH/omxDYBJKo755QKeoIIsWptxnRpgr7reEnNEZGFg==", + "dependencies": { + "@babel/runtime": "^7.23.2", + "@rc-component/portal": "^1.1.0", + "classnames": "^2.3.2", + "rc-motion": "^2.0.0", + "rc-resize-observer": "^1.3.1", + "rc-util": "^5.38.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "packages/components/node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==" + }, "packages/components/node_modules/esbuild": { "version": "0.19.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.5.tgz", @@ -23096,6 +23123,20 @@ "react-dom": ">=16.9.0" } }, + "packages/components/node_modules/rc-tooltip": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/rc-tooltip/-/rc-tooltip-6.2.0.tgz", + "integrity": "sha512-iS/3iOAvtDh9GIx1ulY7EFUXUtktFccNLsARo3NPgLf0QW9oT0w3dA9cYWlhqAKmD+uriEwdWz1kH0Qs4zk2Aw==", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.3.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, "packages/components/node_modules/rollup": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.4.1.tgz", diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index f7898ca..131d3b2 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -1,5 +1,11 @@ # Change Log +## 1.8.1 (2024-04-05) + +### Features + +- Add RangeSlider component (#36) + ## 1.7.0 (2024-03-26) ### Features diff --git a/packages/components/README.md b/packages/components/README.md index d9d19d7..5de3959 100644 --- a/packages/components/README.md +++ b/packages/components/README.md @@ -3,6 +3,7 @@ - `NumberInput` allows to check and input numbers. - `Collapse` allows to display collapsable elements. - `Slider` allows to enter number values by slider and/or NumberInput. +- `RangeSlider` allows to enter number range values by slider. - `Form` allows to render form controls from the declarative config generated by FormBuilder. - `AutosizeCodeEditor` code editor with auto height resizing based on entered content. diff --git a/packages/components/package.json b/packages/components/package.json index 192f6fc..f82221f 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -4,7 +4,9 @@ "@emotion/css": "^11.11.2", "@grafana/data": "^10.2.1", "@grafana/ui": "^10.2.1", - "rc-slider": "^10.5.0" + "classnames": "^2.5.1", + "rc-slider": "^10.5.0", + "rc-tooltip": "^6.2.0" }, "description": "UI Components for Grafana", "devDependencies": { @@ -83,5 +85,5 @@ "typecheck": "tsc --emitDeclarationOnly false --noEmit" }, "types": "dist/index.d.ts", - "version": "1.7.0" + "version": "1.8.1" } diff --git a/packages/components/rollup.config.mjs b/packages/components/rollup.config.mjs index 9974401..1dd9f67 100644 --- a/packages/components/rollup.config.mjs +++ b/packages/components/rollup.config.mjs @@ -16,7 +16,7 @@ export default [ sourcemap: true, }, ], - external: ['react', '@grafana/ui', 'rc-slider', '@emotion/css', '@emotion/react'], + external: ['react', '@grafana/ui', 'rc-slider', '@emotion/css', '@emotion/react', 'rc-tooltip'], }, { input: `src/index.ts`, @@ -25,6 +25,6 @@ export default [ file: `${name}.d.ts`, format: 'es', }, - external: ['react', '@grafana/ui', 'rc-slider', '@emotion/css', '@emotion/react'], + external: ['react', '@grafana/ui', 'rc-slider', '@emotion/css', '@emotion/react', 'rc-tooltip'], }, ]; diff --git a/packages/components/src/__mocks__/rc-slider.tsx b/packages/components/src/__mocks__/rc-slider.tsx index cdb5b58..fe57740 100644 --- a/packages/components/src/__mocks__/rc-slider.tsx +++ b/packages/components/src/__mocks__/rc-slider.tsx @@ -3,17 +3,17 @@ import React from 'react'; /** * Mock RC Slider */ -const RcSlider: React.FC = ({ onChange, ariaLabelForHandle, value }) => { +const RcSlider: React.FC = ({ onChange, ariaLabelForHandle, value, range = false }) => { return ( { if (onChange) { - onChange(Number(event.target.value)); + onChange(range ? (event.target as any).values : Number(event.target.value)); } }} aria-label={ariaLabelForHandle} - value={value} + value={range ? value[0] : value} /> ); }; diff --git a/packages/components/src/components/Form/Form.story.tsx b/packages/components/src/components/Form/Form.story.tsx index b844976..000e7b8 100644 --- a/packages/components/src/components/Form/Form.story.tsx +++ b/packages/components/src/components/Form/Form.story.tsx @@ -27,6 +27,7 @@ const meta = { const Preview = () => { const form = useFormBuilder<{ opacity: number; + normalize: [number, number]; custom: string; color: string; radio: string; @@ -41,6 +42,21 @@ const meta = { max: 100, label: 'Opacity', description: 'Opacity description', + view: { + grow: true, + }, + }) + .addRangeSlider({ + path: 'normalize', + defaultValue: [0, 255], + min: 0, + max: 255, + label: 'Normalize', + description: 'Normalize description', + marks: { 0: '0', 100: '100' }, + view: { + grow: true, + }, }) .addColorPicker({ path: 'color', diff --git a/packages/components/src/components/Form/Form.test.tsx b/packages/components/src/components/Form/Form.test.tsx index 9826879..53fa29a 100644 --- a/packages/components/src/components/Form/Form.test.tsx +++ b/packages/components/src/components/Form/Form.test.tsx @@ -138,6 +138,14 @@ describe('Form', () => { newValue: '2', expectedValue: 2, }, + { + name: 'range slider', + path: 'rangeSlider', + getField: selectors.fieldRangeSlider, + defaultValue: 0, + newValue: [5, 6], + expectedValue: 5, + }, { name: 'number input', path: 'number', @@ -160,6 +168,7 @@ describe('Form', () => { select: string; custom: string; slider: number; + rangeSlider: [number, number]; number: number; color: string; }>({ @@ -188,6 +197,12 @@ describe('Form', () => { min: 0, max: 10, }) + .addRangeSlider({ + path: 'rangeSlider', + defaultValue: [0, 10], + min: 0, + max: 10, + }) .addNumberInput({ path: 'number', defaultValue: path === 'number' ? (defaultValue as any) : 0, @@ -207,9 +222,9 @@ describe('Form', () => { /** * Change Value */ - await act(async () => fireEvent.change(getField(false, path), { target: { value: newValue } })); + await act(async () => fireEvent.change(getField(false, path), { target: { value: newValue, values: newValue } })); - expect(getField(false, path)).toHaveValue(expectedValue); + expect(getField(false, path)).toHaveValue(expectedValue as any); }); it.each([ diff --git a/packages/components/src/components/Form/Form.tsx b/packages/components/src/components/Form/Form.tsx index 9c8eed7..8bea36a 100644 --- a/packages/components/src/components/Form/Form.tsx +++ b/packages/components/src/components/Form/Form.tsx @@ -14,6 +14,7 @@ import React from 'react'; import { TEST_IDS } from '../../constants'; import { FormFieldType, RenderFormField } from '../../types'; import { NumberInput } from '../NumberInput'; +import { RangeSlider } from '../RangeSlider'; import { Slider } from '../Slider'; import { getStyles } from './Form.styles'; @@ -220,6 +221,22 @@ export const Form = ({ ); } + if (field.type === FormFieldType.RANGE_SLIDER) { + return ( + + + + ); + } + if (field.type === FormFieldType.NUMBER_INPUT) { return ( diff --git a/packages/components/src/components/RangeSlider/HandleTooltip.tsx b/packages/components/src/components/RangeSlider/HandleTooltip.tsx new file mode 100644 index 0000000..3643588 --- /dev/null +++ b/packages/components/src/components/RangeSlider/HandleTooltip.tsx @@ -0,0 +1,82 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; +import type { TooltipRef } from 'rc-tooltip'; +import React, { useEffect, useRef } from 'react'; + +/** + * To make it working with grafana build + */ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { default: Tooltip } = require('rc-tooltip'); + +export const HandleTooltip = (props: { + value: number; + children: React.ReactElement; + visible: boolean; + placement: 'bottom' | 'right'; + tipFormatter?: () => React.ReactNode; +}) => { + const { value, children, visible, placement, tipFormatter, ...restProps } = props; + + const tooltipRef = useRef(null); + const rafRef = useRef(null); + const styles = useStyles2(tooltipStyles); + + function cancelKeepAlign() { + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + } + } + + function keepAlign() { + rafRef.current = requestAnimationFrame(() => { + tooltipRef.current?.forceAlign(); + }); + } + + useEffect(() => { + if (visible) { + keepAlign(); + } else { + cancelKeepAlign(); + } + + return cancelKeepAlign; + }, [value, visible]); + + return ( + + {children} + + ); +}; + +const tooltipStyles = (theme: GrafanaTheme2) => { + return { + tooltip: css({ + position: 'absolute', + display: 'block', + visibility: 'visible', + fontSize: theme.typography.bodySmall.fontSize, + backgroundColor: theme.colors.background.primary, + opacity: 0.9, + /** + * Should be higher to be visible in Drawers + */ + zIndex: theme.zIndex.portal, + padding: theme.spacing(0, 0.5), + }), + }; +}; diff --git a/packages/components/src/components/RangeSlider/RangeSlider.styles.ts b/packages/components/src/components/RangeSlider/RangeSlider.styles.ts new file mode 100644 index 0000000..e93440c --- /dev/null +++ b/packages/components/src/components/RangeSlider/RangeSlider.styles.ts @@ -0,0 +1,131 @@ +import { css } from '@emotion/css'; +import { css as cssCore } from '@emotion/react'; +import { GrafanaTheme2 } from '@grafana/data'; +import { stylesFactory } from '@grafana/ui'; + +// eslint-disable-next-line deprecation/deprecation +export const getStyles = stylesFactory((theme: GrafanaTheme2, isHorizontal: boolean, hasMarks = false) => { + const { spacing } = theme; + const railColor = theme.colors.border.strong; + const trackColor = theme.colors.primary.main; + const handleColor = theme.colors.primary.main; + const blueOpacity = theme.colors.primary.transparent; + const hoverStyle = `box-shadow: 0px 0px 0px 6px ${blueOpacity}`; + + return { + container: css({ + width: '100%', + margin: isHorizontal ? spacing(1, 0, 2.5) : spacing(1, 3, 1, 1), + paddingBottom: isHorizontal && hasMarks ? theme.spacing(1) : 'inherit', + height: isHorizontal ? 'auto' : '100%', + }), + // can't write this as an object since it needs to overwrite rc-slider styles + // object syntax doesn't support kebab case keys + slider: css` + .rc-slider { + display: flex; + flex-grow: 1; + margin-left: 7px; // half the size of the handle to align handle to the left on 0 value + } + .rc-slider-mark { + top: ${theme.spacing(1.75)}; + } + .rc-slider-mark-text { + color: ${theme.colors.text.disabled}; + font-size: ${theme.typography.bodySmall.fontSize}; + } + .rc-slider-mark-text-active { + color: ${theme.colors.text.primary}; + } + .rc-slider-handle { + border: none; + background-color: ${handleColor}; + box-shadow: ${theme.shadows.z1}; + cursor: pointer; + opacity: 1; + } + + .rc-slider-handle:hover, + .rc-slider-handle:active, + .rc-slider-handle-click-focused:focus { + ${hoverStyle}; + } + + // The triple class names is needed because that's the specificity used in the source css :( + .rc-slider-handle-dragging.rc-slider-handle-dragging.rc-slider-handle-dragging, + .rc-slider-handle:focus-visible { + box-shadow: 0 0 0 4px ${theme.colors.text.primary}; + } + + .rc-slider-dot, + .rc-slider-dot-active { + background-color: ${theme.colors.text.primary}; + border-color: ${theme.colors.text.primary}; + } + + .rc-slider-track { + background-color: ${trackColor}; + } + .rc-slider-rail { + background-color: ${railColor}; + cursor: pointer; + } + `, + /** Global component from @emotion/core doesn't accept computed classname string returned from css from emotion. + * It accepts object containing the computed name and flattened styles returned from css from @emotion/core + * */ + tooltip: cssCore` + body { + .rc-slider-tooltip { + cursor: grab; + user-select: none; + z-index: ${theme.zIndex.tooltip}; + } + + .rc-slider-tooltip-inner { + color: ${theme.colors.text.primary}; + background-color: transparent !important; + border-radius: 0; + box-shadow: none; + } + + .rc-slider-tooltip-placement-top .rc-slider-tooltip-arrow { + display: none; + } + + .rc-slider-tooltip-placement-top { + padding: 0; + } + + .rc-tooltip-hidden { + display: none; + } + } + `, + sliderInput: css({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + width: '100%', + }), + sliderInputVertical: css({ + flexDirection: 'column', + height: '100%', + + '.rc-slider': { + margin: 0, + order: 2, + }, + }), + sliderInputField: css({ + marginLeft: theme.spacing(3), + input: { + textAlign: 'center', + }, + }), + sliderInputFieldVertical: css({ + margin: `0 0 ${theme.spacing(3)} 0`, + order: 1, + }), + }; +}); diff --git a/packages/components/src/components/RangeSlider/RangeSlider.tsx b/packages/components/src/components/RangeSlider/RangeSlider.tsx new file mode 100644 index 0000000..6a8cb98 --- /dev/null +++ b/packages/components/src/components/RangeSlider/RangeSlider.tsx @@ -0,0 +1,130 @@ +import { cx } from '@emotion/css'; +import { Global } from '@emotion/react'; +import { useTheme2 } from '@grafana/ui'; +import type { SliderProps } from 'rc-slider'; +import React, { useCallback, useEffect, useState } from 'react'; + +import { HandleTooltip } from './HandleTooltip'; +import { getStyles } from './RangeSlider.styles'; +import { RangeSliderProps } from './types'; + +/** + * To make it working with grafana build + */ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { default: SliderComponent } = require('rc-slider'); + +/** + * Properties + */ +interface Props extends Omit { + /** + * Test ID + * + * @type {string} + */ + ['data-testid']?: string; + + /** + * Slider Aria Label + * + * @type {string} + */ + sliderAriaLabel?: string; + + /** + * Disabled + * + * @type {boolean} + */ + disabled?: boolean; +} + +/** + * Range Slider + */ +export const RangeSlider: React.FC = ({ + min, + max, + onChange, + onAfterChange, + orientation = 'horizontal', + reverse, + step, + value, + marks, + included, + sliderAriaLabel, + disabled, + formatTooltipResult, +}) => { + const isHorizontal = orientation === 'horizontal'; + const theme = useTheme2(); + const styles = getStyles(theme, isHorizontal, Boolean(marks)); + const SliderWithTooltip = SliderComponent; + const [sliderValue, setSliderValue] = useState(value ?? [min, max]); + + const onSliderChange = useCallback( + (value: number[]) => { + setSliderValue(value); + onChange?.([value[0], value[1]]); + }, + [setSliderValue, onChange] + ); + + const handleAfterChange = useCallback( + (value: number[]) => { + onAfterChange?.([value[0], value[1]]); + }, + [onAfterChange] + ); + + useEffect(() => { + if (value !== sliderValue) { + setSliderValue(value ?? [min, max]); + } + }, [value, sliderValue, min, max]); + + const sliderInputClassNames = !isHorizontal ? [styles.sliderInputVertical] : []; + + const tipHandleRender: SliderProps['handleRender'] = (node, handleProps) => { + return ( + formatTooltipResult(handleProps.value) : undefined} + placement={isHorizontal ? 'bottom' : 'right'} + > + {node} + + ); + }; + + return ( +
+ {/** Slider tooltip's parent component is body and therefore we need Global component to do css overrides for it. */} + +
+ +
+
+ ); +}; + +RangeSlider.displayName = 'RangeSlider'; diff --git a/packages/components/src/components/RangeSlider/index.ts b/packages/components/src/components/RangeSlider/index.ts new file mode 100644 index 0000000..8bb8a4f --- /dev/null +++ b/packages/components/src/components/RangeSlider/index.ts @@ -0,0 +1 @@ +export * from './RangeSlider'; diff --git a/packages/components/src/components/RangeSlider/types.ts b/packages/components/src/components/RangeSlider/types.ts new file mode 100644 index 0000000..de1d8bd --- /dev/null +++ b/packages/components/src/components/RangeSlider/types.ts @@ -0,0 +1,39 @@ +import { SliderMarks } from '@grafana/data'; + +export type Orientation = 'horizontal' | 'vertical'; + +interface CommonSliderProps { + min: number; + max: number; + orientation?: Orientation; + /** Set current positions of handle(s). If only 1 value supplied, only 1 handle displayed. */ + reverse?: boolean; + step?: number; + tooltipAlwaysVisible?: boolean; + /** Marks on the slider. The key determines the position, and the value determines what will show. If you want to set the style of a specific mark point, the value should be an object which contains style and label properties. */ + marks?: SliderMarks; + /** If the value is true, it means a continuous value interval, otherwise, it is a independent value. */ + included?: boolean; +} + +/** + * Slider Properties + */ +export interface SliderProps extends CommonSliderProps { + value?: number; + onChange?: (value: number) => void; + onAfterChange?: (value?: number) => void; + formatTooltipResult?: (value: number) => number; + ariaLabelForHandle?: string; +} + +/** + * Range Slider Properties + */ +export interface RangeSliderProps extends CommonSliderProps { + value?: [number, number]; + onChange?: (value: [number, number]) => void; + onAfterChange?: (value?: [number, number]) => void; + formatTooltipResult?: (value: number) => number; + ariaLabelForHandle?: string; +} diff --git a/packages/components/src/components/index.ts b/packages/components/src/components/index.ts index dd445de..cd906a8 100644 --- a/packages/components/src/components/index.ts +++ b/packages/components/src/components/index.ts @@ -2,4 +2,5 @@ export * from './AutosizeCodeEditor'; export * from './Collapse'; export * from './Form'; export * from './NumberInput'; +export * from './RangeSlider'; export * from './Slider'; diff --git a/packages/components/src/constants/tests.ts b/packages/components/src/constants/tests.ts index 397f5fa..7676bc2 100644 --- a/packages/components/src/constants/tests.ts +++ b/packages/components/src/constants/tests.ts @@ -9,6 +9,7 @@ export const TEST_IDS = { fieldSelect: (name: unknown) => `form field-select-${name}`, fieldCustom: (name: unknown) => `data-testid form field-custom-${name}`, fieldSlider: (name: unknown) => `data-testid form field-slider-${name}`, + fieldRangeSlider: (name: unknown) => `form field-range-slider-${name}`, fieldNumberInput: (name: unknown) => `data-testid form field-number-input-${name}`, fieldColor: (name: unknown) => `data-testid form field-color-${name}`, fieldInput: (name: unknown) => `data-testid form field-input-${name}`, diff --git a/packages/components/src/types/form-builder.ts b/packages/components/src/types/form-builder.ts index 8db6996..af721b4 100644 --- a/packages/components/src/types/form-builder.ts +++ b/packages/components/src/types/form-builder.ts @@ -162,6 +162,36 @@ export interface SliderOptions extends BaseOptio marks?: Record; } +export interface RangeSliderOptions extends BaseOptions { + /** + * Min + * + * @type {number} + */ + min: number; + + /** + * Max + * + * @type {number} + */ + max: number; + + /** + * Step + * + * @type {number} + */ + step?: number; + + /** + * Marks + * + * @type {Record} + */ + marks?: Record; +} + export interface CustomOptions extends BaseOptions { /** * Component @@ -240,6 +270,7 @@ export enum FormFieldType { HIDDEN = 'hidden', INPUT = 'input', NUMBER_INPUT = 'numberInput', + RANGE_SLIDER = 'rangeSlider', } /** @@ -255,6 +286,9 @@ export type FormField = | ({ type: FormFieldType.SLIDER; } & SliderOptions) + | ({ + type: FormFieldType.RANGE_SLIDER; + } & RangeSliderOptions) | ({ type: FormFieldType.CUSTOM; } & CustomOptions) @@ -293,6 +327,11 @@ export type RenderFormField = value: number; onChange: (value: number) => void; } & SliderOptions) + | ({ + type: FormFieldType.RANGE_SLIDER; + value: [number, number]; + onChange: (value: [number, number]) => void; + } & RangeSliderOptions) | ({ type: FormFieldType.CUSTOM; value: TObject[keyof TObject]; diff --git a/packages/components/src/utils/form-builder.ts b/packages/components/src/utils/form-builder.ts index e7ce221..c134d4d 100644 --- a/packages/components/src/utils/form-builder.ts +++ b/packages/components/src/utils/form-builder.ts @@ -8,6 +8,7 @@ import { IsObject, NumberInputOptions, RadioOptions, + RangeSliderOptions, RenderFormField, SelectOptions, SliderOptions, @@ -142,6 +143,17 @@ export class FormBuilder { }); } + /** + * Add Range Slider Field + * @param options + */ + addRangeSlider>(options: TOptions) { + return this.addField({ + ...options, + type: FormFieldType.RANGE_SLIDER, + }); + } + /** * Add Color Picker Field * @param options