From 971fea20624990512989752d4c644a7baf55acbe Mon Sep 17 00:00:00 2001
From: Cee Chen <549407+cee-chen@users.noreply.github.com>
Date: Wed, 3 Jan 2024 11:12:10 -0800
Subject: [PATCH] [EuiRange][EuiDualRange] Respond to resizing/layout changes
(#7442)
---
changelogs/upcoming/7442.md | 3 +
.../__snapshots__/dual_range.test.tsx.snap | 50 ++---
src/components/form/range/dual_range.test.tsx | 31 +--
src/components/form/range/dual_range.tsx | 172 ++++++----------
src/components/form/range/range.spec.tsx | 188 ++++++++++++++++++
src/components/form/range/range.tsx | 6 +-
src/components/form/range/range_slider.tsx | 130 ++++++------
7 files changed, 347 insertions(+), 233 deletions(-)
create mode 100644 changelogs/upcoming/7442.md
create mode 100644 src/components/form/range/range.spec.tsx
diff --git a/changelogs/upcoming/7442.md b/changelogs/upcoming/7442.md
new file mode 100644
index 00000000000..8c133f39e6f
--- /dev/null
+++ b/changelogs/upcoming/7442.md
@@ -0,0 +1,3 @@
+**Bug fixes**
+
+- `EuiRange`/`EuiDualRange`'s track ticks & highlights now update their positions on resize
diff --git a/src/components/form/range/__snapshots__/dual_range.test.tsx.snap b/src/components/form/range/__snapshots__/dual_range.test.tsx.snap
index d7167838120..341ba7373e8 100644
--- a/src/components/form/range/__snapshots__/dual_range.test.tsx.snap
+++ b/src/components/form/range/__snapshots__/dual_range.test.tsx.snap
@@ -27,7 +27,7 @@ exports[`EuiDualRange props isDraggable renders draggable track when isDraggable
aria-valuetext="1, 8"
class="euiRangeDraggable emotion-euiRangeDraggable"
role="slider"
- style="inset-inline-start: -Infinity%; inset-inline-end: calc(100% - -Infinity% - 16px);"
+ style="inset-inline-start: 0; inset-inline-end: calc(100% - 0 - 16px);"
tabindex="0"
>
@@ -698,7 +698,7 @@ exports[`EuiDualRange props showRange renders range when showRange=true 1`] = `
aria-valuenow="1"
class="euiRangeThumb emotion-euiRangeThumb"
role="slider"
- style="inset-inline-start: -Infinity%;"
+ style="inset-inline-start: 0;"
tabindex="0"
/>
@@ -793,7 +793,7 @@ exports[`EuiDualRange props value accepts numbers 1`] = `
aria-valuenow="1"
class="euiRangeThumb emotion-euiRangeThumb"
role="slider"
- style="inset-inline-start: -Infinity%;"
+ style="inset-inline-start: 0;"
tabindex="0"
/>
{
});
});
});
-
- describe('ref methods', () => {
- // Whether we like it or not, at least 2 Kibana instances are using EuiDualRange
- // `ref`s to access the `onResize` instance method (search for `rangeRef.current?.onResize`)
- // If we switch EuiDualRange to a function component, we'll need to use `useImperativeHandle`
- // to allow Kibana to continue calling `onResize`
- it('allows calling the internal onResize method', () => {
- // This super annoying type is now required to pass both the `ref` typing and account for instance methods
- type EuiDualRangeRef = React.ComponentProps &
- EuiDualRangeClass;
-
- const ConsumerDualRange = () => {
- const rangeRef = useRef(null);
-
- useEffect(() => {
- rangeRef.current?.onResize(500);
- }, []);
-
- return ;
- };
-
- render();
-
- // There isn't anything we can assert on here that isn't a huge headache,
- // but the test should fail if `ref.current.onResize` is no longer available
- });
- });
});
diff --git a/src/components/form/range/dual_range.tsx b/src/components/form/range/dual_range.tsx
index f9b78683fce..ec196218757 100644
--- a/src/components/form/range/dual_range.tsx
+++ b/src/components/form/range/dual_range.tsx
@@ -59,10 +59,8 @@ export class EuiDualRangeClass extends Component<
state = {
id: this.props.id || htmlIdGenerator()(),
- rangeSliderRefAvailable: false,
isPopoverOpen: false,
rangeWidth: 0,
- isVisible: true, // used to trigger a rerender if initial element width is 0
};
get isInPopover() {
@@ -70,32 +68,6 @@ export class EuiDualRangeClass extends Component<
}
preventPopoverClose = false;
- rangeSliderRef: HTMLInputElement | null = null;
- handleRangeSliderRefUpdate = (ref: HTMLInputElement | null) => {
- this.rangeSliderRef = ref;
- if (ref) {
- if (this.isInPopover) {
- // Wait a tick for popover rendering to settle
- requestAnimationFrame(() => {
- this.setState({
- rangeSliderRefAvailable: true,
- rangeWidth: ref.clientWidth,
- });
- });
- } else {
- // If not in a popover, no need to wait
- this.setState({
- rangeSliderRefAvailable: true,
- rangeWidth: ref.clientWidth,
- });
- }
- } else {
- this.setState({
- rangeSliderRefAvailable: false,
- rangeWidth: 0,
- });
- }
- };
private leftPosition = 0;
private dragAcc = 0;
@@ -115,18 +87,6 @@ export class EuiDualRangeClass extends Component<
return this.lowerValueIsValid && this.upperValueIsValid;
}
- componentDidMount() {
- if (this.rangeSliderRef?.clientWidth === 0) {
- this.setState({ isVisible: false });
- }
- }
-
- componentDidUpdate() {
- if (this.rangeSliderRef?.clientWidth && !this.state.isVisible) {
- this.setState({ isVisible: true });
- }
- }
-
_determineInvalidThumbMovement = (
newVal: ValueMember,
lower: ValueMember,
@@ -397,13 +357,8 @@ export class EuiDualRangeClass extends Component<
});
};
- onResize = (width: number) => {
- if (this.rangeSliderRef) {
- this.setState({
- rangeWidth: this.rangeSliderRef.clientWidth,
- });
- }
- this.props.inputPopoverProps?.onPanelResize?.(width);
+ setRangeWidth = ({ width }: { width: number }) => {
+ this.setState({ rangeWidth: width });
};
getNearestStep = (value: number) => {
@@ -428,7 +383,7 @@ export class EuiDualRangeClass extends Component<
const delta = this.leftPosition - x;
this.leftPosition = x;
this.dragAcc = this.dragAcc + delta;
- const percentageOfArea = this.dragAcc / this.rangeSliderRef!.clientWidth;
+ const percentageOfArea = this.dragAcc / this.state.rangeWidth;
const percentageOfRange = percentageOfArea * (max - min);
const newLower = this.getNearestStep(lowerValue - percentageOfRange);
const newUpper = this.getNearestStep(upperValue - percentageOfRange);
@@ -605,13 +560,13 @@ export class EuiDualRangeClass extends Component<
const dualRangeStyles = euiDualRangeStyles();
const cssStyles = [dualRangeStyles.euiDualRange, customCss];
- const leftThumbPosition = this.state.rangeSliderRefAvailable
+ const leftThumbPosition = this.state.rangeWidth
? this.calculateThumbPositionStyle(
Number(this.lowerValue) || min,
this.state.rangeWidth
)
: { left: '0' };
- const rightThumbPosition = this.state.rangeSliderRefAvailable
+ const rightThumbPosition = this.state.rangeWidth
? this.calculateThumbPositionStyle(
Number(this.upperValue) || max,
this.state.rangeWidth
@@ -689,7 +644,6 @@ export class EuiDualRangeClass extends Component<
- {this.state.rangeSliderRefAvailable && (
- <>
- {isDraggable && this.isValid && (
-
- )}
-
-
-
-
- >
+ {isDraggable && this.isValid && (
+
)}
+
+
+
+
{showRange && this.isValid && (
{theRange}
diff --git a/src/components/form/range/range.spec.tsx b/src/components/form/range/range.spec.tsx
new file mode 100644
index 00000000000..255e71871cf
--- /dev/null
+++ b/src/components/form/range/range.spec.tsx
@@ -0,0 +1,188 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+///
+///
+///
+
+import React from 'react';
+
+import { EuiRange } from './range';
+import { EuiDualRange } from './dual_range';
+
+const sharedProps = {
+ min: 0,
+ max: 100,
+ onChange: () => {},
+ showTicks: true,
+ showLabels: true,
+ levels: [
+ {
+ min: 0,
+ max: 20,
+ color: 'danger',
+ },
+ {
+ min: 20,
+ max: 100,
+ color: 'success',
+ },
+ ],
+};
+const firstExpectedLevel = /^0px 255[.0-9]+px$/;
+const secondExpectedLevel = /^71[.0-9]+px 0px$/;
+
+describe('EuiRange', () => {
+ const props = {
+ ...sharedProps,
+ value: 50,
+ showValue: true,
+ showRange: true,
+ tickInterval: 20,
+ };
+
+ // TODO: These should likely be visual snapshot regression tests instead
+ const assertRangePositions = () => {
+ // Ticks
+ cy.get('.euiRangeTick')
+ .first()
+ .should('have.css', 'inset-inline-start', '8px');
+ cy.get('.euiRangeTick')
+ .eq(3)
+ .should('have.css', 'inset-inline-start')
+ .and('match', /^195[.0-9]+px$/);
+ cy.get('.euiRangeTick')
+ .last()
+ .should('have.css', 'inset-inline-start')
+ .and('match', /^319[.0-9]+px$/);
+
+ // Levels - present in both EuiRangeLevels and EuiHighlight
+ cy.get('.euiRangeLevel')
+ .eq(0)
+ .should('have.css', 'inset-inline')
+ .and('match', firstExpectedLevel);
+ cy.get('.euiRangeLevel')
+ .eq(2)
+ .should('have.css', 'inset-inline')
+ .and('match', firstExpectedLevel);
+
+ cy.get('.euiRangeLevel')
+ .eq(1)
+ .should('have.css', 'inset-inline')
+ .and('match', secondExpectedLevel);
+ cy.get('.euiRangeLevel')
+ .eq(3)
+ .should('have.css', 'inset-inline')
+ .and('match', secondExpectedLevel);
+
+ // Highlight
+ cy.get('.euiRangeHighlight > div')
+ .should('have.css', 'margin-inline-start', '0px')
+ .should('have.css', 'inline-size')
+ .and('match', /^163[.0-9]+px$/);
+
+ // Tooltip
+ cy.get('.euiRangeTooltip > output')
+ .should('have.css', 'inset-inline-start')
+ .and('match', /^155[.0-9]+px$/);
+ };
+
+ it('renders ticks, levels, highlights, and tooltips in their correct positions', () => {
+ cy.mount();
+ assertRangePositions();
+ });
+
+ it('inputWithPopover', () => {
+ cy.realMount(
+
+ );
+ cy.realPress('Tab');
+ assertRangePositions();
+ });
+});
+
+describe('EuiDualRange', () => {
+ const props = {
+ ...sharedProps,
+ value: [10, 80] as [number, number],
+ ticks: [
+ { label: '20kb', value: 20 },
+ { label: '100kb', value: 100 },
+ ],
+ };
+
+ // TODO: These should likely be visual snapshot regression tests instead
+ const assertRangePositions = () => {
+ // Ticks
+ cy.get('.euiRangeTick')
+ .first()
+ .should('have.css', 'inset-inline-start')
+ .and('match', /^69[.0-9]+px$/);
+ cy.get('.euiRangeTick')
+ .last()
+ .should('have.css', 'inset-inline-end', '0px');
+
+ // Levels - present in both EuiRangeLevels and EuiHighlight
+ cy.get('.euiRangeLevel')
+ .eq(0)
+ .should('have.css', 'inset-inline')
+ .and('match', firstExpectedLevel);
+ cy.get('.euiRangeLevel')
+ .eq(2)
+ .should('have.css', 'inset-inline')
+ .and('match', firstExpectedLevel);
+
+ cy.get('.euiRangeLevel')
+ .eq(1)
+ .should('have.css', 'inset-inline')
+ .and('match', secondExpectedLevel);
+ cy.get('.euiRangeLevel')
+ .eq(3)
+ .should('have.css', 'inset-inline')
+ .and('match', secondExpectedLevel);
+
+ // Highlight
+ cy.get('.euiRangeHighlight > div')
+ .should('have.css', 'margin-inline-start')
+ .and('match', /^32[.0-9]+px$/);
+ cy.get('.euiRangeHighlight > div')
+ .should('have.css', 'inline-size')
+ .and('match', /^229[.0-9]+px$/);
+
+ // Thumbs
+ cy.get('.euiRangeThumb')
+ .first()
+ .should('have.css', 'inset-inline-start')
+ .and('match', /^31[.0-9]+px$/);
+ cy.get('.euiRangeThumb')
+ .last()
+ .should('have.css', 'inset-inline-start')
+ .and('match', /^249[.0-9]+px$/);
+ };
+
+ it('renders ticks, levels, highlights, and thumbs in their correct positions', () => {
+ cy.mount();
+ assertRangePositions();
+ });
+
+ it('inputWithPopover', () => {
+ cy.realMount(
+
+ );
+ cy.realPress('Tab');
+ assertRangePositions();
+ });
+});
diff --git a/src/components/form/range/range.tsx b/src/components/form/range/range.tsx
index da6e13fcf42..9ef374c178a 100644
--- a/src/components/form/range/range.tsx
+++ b/src/components/form/range/range.tsx
@@ -80,8 +80,8 @@ export class EuiRangeClass extends Component<
);
}
- rangeSliderRef = (ref: HTMLInputElement | null) => {
- this.setState({ trackWidth: ref?.clientWidth || 0 });
+ setTrackWidth = ({ width }: { width: number }) => {
+ this.setState({ trackWidth: width });
};
onInputFocus = (e: React.FocusEvent) => {
@@ -241,7 +241,7 @@ export class EuiRangeClass extends Component<
aria-hidden={!!showInput}
thumbColor={thumbColor}
{...rest}
- ref={this.rangeSliderRef}
+ onResize={this.setTrackWidth}
/>
{showRange && this.isValid && (
diff --git a/src/components/form/range/range_slider.tsx b/src/components/form/range/range_slider.tsx
index 76f6a2f413c..a6de8e80b60 100644
--- a/src/components/form/range/range_slider.tsx
+++ b/src/components/form/range/range_slider.tsx
@@ -9,7 +9,7 @@
import React, {
ChangeEventHandler,
InputHTMLAttributes,
- forwardRef,
+ FunctionComponent,
useMemo,
} from 'react';
import classNames from 'classnames';
@@ -17,6 +17,10 @@ import classNames from 'classnames';
import { CommonProps } from '../../common';
import { useEuiTheme } from '../../../services';
import { logicalStyles } from '../../../global_styling';
+import {
+ EuiResizeObserver,
+ EuiResizeObserverProps,
+} from '../../observer/resize_observer';
import type { EuiRangeProps, EuiRangeLevel } from './types';
import { euiRangeLevelColor } from './range_levels_colors';
@@ -26,7 +30,10 @@ import {
} from './range_slider.styles';
export interface EuiRangeSliderProps
- extends Omit, 'min' | 'max' | 'step'>,
+ extends Omit<
+ InputHTMLAttributes,
+ 'min' | 'max' | 'step' | 'onResize'
+ >,
CommonProps,
Pick<
EuiRangeProps,
@@ -43,68 +50,67 @@ export interface EuiRangeSliderProps
> {
onChange?: ChangeEventHandler;
thumbColor?: EuiRangeLevel['color'];
+ onResize: EuiResizeObserverProps['onResize'];
}
-export const EuiRangeSlider = forwardRef(
- (
- {
- className,
- disabled,
- id,
- max,
- min,
- name,
- step,
- onChange,
- tabIndex,
- value,
- style,
- showTicks,
- showRange,
- thumbColor,
- ...rest
- },
- ref
- ) => {
- const classes = classNames('euiRangeSlider', className);
-
- const euiTheme = useEuiTheme();
- const styles = euiRangeSliderStyles(euiTheme);
- const thumbStyles = euiRangeSliderThumbStyles(euiTheme);
- const cssStyles = [
- styles.euiRangeSlider,
- showTicks && styles.hasTicks,
- showRange && styles.hasRange,
- thumbColor && thumbStyles.thumb,
- ];
+export const EuiRangeSlider: FunctionComponent = ({
+ className,
+ disabled,
+ id,
+ max,
+ min,
+ name,
+ step,
+ onChange,
+ tabIndex,
+ value,
+ style,
+ showTicks,
+ showRange,
+ thumbColor,
+ onResize,
+ ...rest
+}) => {
+ const classes = classNames('euiRangeSlider', className);
- const sliderStyle = useMemo(() => {
- return logicalStyles({
- color: thumbColor && euiRangeLevelColor(thumbColor, euiTheme.euiTheme),
- ...style,
- });
- }, [thumbColor, euiTheme, style]);
+ const euiTheme = useEuiTheme();
+ const styles = euiRangeSliderStyles(euiTheme);
+ const thumbStyles = euiRangeSliderThumbStyles(euiTheme);
+ const cssStyles = [
+ styles.euiRangeSlider,
+ showTicks && styles.hasTicks,
+ showRange && styles.hasRange,
+ thumbColor && thumbStyles.thumb,
+ ];
- return (
-
- );
- }
-);
+ const sliderStyle = useMemo(() => {
+ return logicalStyles({
+ color: thumbColor && euiRangeLevelColor(thumbColor, euiTheme.euiTheme),
+ ...style,
+ });
+ }, [thumbColor, euiTheme, style]);
-EuiRangeSlider.displayName = 'EuiRangeSlider';
+ return (
+
+ {(resizeRef) => (
+
+ )}
+
+ );
+};