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) => ( + + )} + + ); +};