/* * SPDX-License-Identifier: Apache-2.0 * * The OpenSearch Contributors require contributions made to * this file be licensed under the Apache-2.0 license or a * compatible open source license. * * Modifications Copyright OpenSearch Contributors. See * GitHub history for details. */ /* * Licensed to Elasticsearch B.V. under one or more contributor * license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright * ownership. Elasticsearch B.V. licenses this file to you under * the Apache License, Version 2.0 (the "License"); you may * not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ import React, { Component } from 'react'; import classNames from 'classnames'; import { keys } from '../../../services'; import { isWithinRange } from '../../../services/number'; import { OuiInputPopover } from '../../popover'; import { OuiFormControlLayoutDelimited, OuiFormControlLayoutProps, } from '../form_control_layout'; import { htmlIdGenerator } from '../../../services/accessibility'; import { OuiRangeProps } from './range'; import { OuiRangeDraggable } from './range_draggable'; import { OuiRangeHighlight } from './range_highlight'; import { OuiRangeInput, OuiRangeInputProps } from './range_input'; import { OuiRangeLabel } from './range_label'; import { OuiRangeLevel } from './range_levels'; import { OuiRangeSlider, OuiRangeSliderProps } from './range_slider'; import { OuiRangeThumb } from './range_thumb'; import { OuiRangeTick } from './range_ticks'; import { OuiRangeTrack } from './range_track'; import { OuiRangeWrapper } from './range_wrapper'; import { calculateThumbPosition } from './utils'; type ValueMember = number | string; export interface OuiDualRangeProps extends Omit< OuiRangeSliderProps, 'onChange' | 'onBlur' | 'onFocus' | 'value' > { value: [ValueMember, ValueMember]; onBlur?: ( event: React.FocusEvent | React.FocusEvent ) => void; onFocus?: ( event: React.FocusEvent | React.FocusEvent ) => void; onChange: ( values: [ValueMember, ValueMember], isValid: boolean, event?: | React.ChangeEvent | React.MouseEvent | React.KeyboardEvent | React.KeyboardEvent ) => void; fullWidth?: boolean; isInvalid?: boolean; /** * Create colored indicators for certain intervals */ levels?: OuiRangeLevel[]; /** * Shows static min/max labels on the sides of the range slider */ showLabels?: boolean; /** * Pass `true` to displays an extra input control for direct manipulation. * Pass `'inputWithPopover'` to only show the input but show the range in a dropdown. */ showInput?: OuiRangeProps['showInput']; /** * Modifies the number of tick marks and at what interval */ tickInterval?: number; /** * Specified ticks at specified values */ ticks?: OuiRangeTick[]; /** * Creates an input group with element(s) coming before input. Will only show if `showInput = inputWithPopover`. * `string` | `ReactElement` or an array of these */ prepend?: OuiFormControlLayoutProps['prepend']; /** * Creates an input group with element(s) coming after input. Will only show if `showInput = inputWithPopover`. * `string` | `ReactElement` or an array of these */ append?: OuiFormControlLayoutProps['append']; /** * Intended to be uses with aria attributes. Some attributes may be overwritten. */ minInputProps?: Partial; /** * Intended to be uses with aria attributes. Some attributes may be overwritten. */ maxInputProps?: Partial; /** * Creates a draggble highlighted range area */ isDraggable?: boolean; } export class OuiDualRange extends Component { static defaultProps = { min: 0, max: 100, step: 1, fullWidth: false, compressed: false, showLabels: false, showInput: false, showRange: true, showTicks: false, levels: [], }; state = { id: this.props.id || htmlIdGenerator()(), hasFocus: false, rangeSliderRefAvailable: false, isPopoverOpen: false, rangeWidth: undefined, isVisible: true, // used to trigger a rerender if initial element width is 0 }; preventPopoverClose = false; rangeSliderRef: HTMLInputElement | null = null; handleRangeSliderRefUpdate = (ref: HTMLInputElement | null) => { this.rangeSliderRef = ref; this.setState({ rangeSliderRefAvailable: !!ref, rangeWidth: !!ref ? ref.clientWidth : null, }); }; private leftPosition = 0; private dragAcc = 0; get lowerValue() { return this.props.value ? this.props.value[0] : this.props.min; } get upperValue() { return this.props.value ? this.props.value[1] : this.props.max; } get lowerValueIsValid() { return isWithinRange(this.props.min, this.upperValue, this.lowerValue); } get upperValueIsValid() { return isWithinRange(this.lowerValue, this.props.max, this.upperValue); } get isValid() { return this.lowerValueIsValid && this.upperValueIsValid; } componentDidMount() { if (this.rangeSliderRef && this.rangeSliderRef.clientWidth === 0) { // Safe to call `setState` inside conditional // https://reactjs.org/docs/react-component.html#componentdidmount // eslint-disable-next-line react/no-did-mount-set-state this.setState({ isVisible: false }); } } componentDidUpdate() { if (this.rangeSliderRef?.clientWidth && !this.state.isVisible) { // Safe to call `setState` inside conditional // https://reactjs.org/docs/react-component.html#componentdidupdate // eslint-disable-next-line react/no-did-update-set-state this.setState({ isVisible: true }); } } _determineInvalidThumbMovement = ( newVal: ValueMember, lower: ValueMember, upper: ValueMember, e: | React.ChangeEvent | React.MouseEvent | React.KeyboardEvent ) => { // If the values are invalid, find whether the new value is in the upper // or lower half and move the appropriate handle to the new value, // while the other handle gets moved to the opposite bound (if invalid) const lowerHalf = Math.abs(this.props.max - this.props.min) / 2 + this.props.min; const newValIsLow = isWithinRange(this.props.min, lowerHalf, newVal); if (newValIsLow) { lower = newVal; upper = !this.upperValueIsValid ? this.props.max : upper; } else { lower = !this.lowerValueIsValid ? this.props.min : lower; upper = newVal; } this._handleOnChange(lower, upper, e); }; _determineValidThumbMovement = ( newVal: ValueMember, lower: ValueMember, upper: ValueMember, e: React.ChangeEvent | React.MouseEvent ) => { // Lower thumb targeted or right-moving swap has occurred if ( Math.abs((lower as number) - (newVal as number)) < Math.abs((upper as number) - (newVal as number)) ) { lower = newVal; } // Upper thumb targeted or left-moving swap has occurred else { upper = newVal; } this._handleOnChange(lower, upper, e); }; _determineThumbMovement = ( newVal: number, e: React.ChangeEvent | React.MouseEvent ) => { // Determine thumb movement based on slider interaction if (!this.isValid) { // Non-standard positioning follows this._determineInvalidThumbMovement( newVal, this.lowerValue, this.upperValue, e ); } else { // Standard positioning based on click event proximity to thumb locations this._determineValidThumbMovement( newVal, this.lowerValue, this.upperValue, e ); } }; _handleOnChange = ( lower: ValueMember, upper: ValueMember, e?: | React.ChangeEvent | React.MouseEvent | React.KeyboardEvent | React.KeyboardEvent ) => { const isValid = isWithinRange(this.props.min, upper, lower) && isWithinRange(lower, this.props.max, upper); this.props.onChange([lower, upper], isValid, e); }; handleSliderChange = ( e: React.ChangeEvent | React.MouseEvent ) => { this._determineThumbMovement(Number(e.currentTarget.value), e); }; _resetToRangeEnds = (e: React.KeyboardEvent) => { // Arbitrary decision to pass `min` instead of `max`. Result is the same. this._determineInvalidThumbMovement( this.props.min, this.lowerValue, this.upperValue, e ); }; _isDirectionalKeyPress = (event: React.KeyboardEvent) => { return ( [ keys.ARROW_UP, keys.ARROW_RIGHT, keys.ARROW_DOWN, keys.ARROW_LEFT, ].indexOf(event.key) > -1 ); }; handleInputKeyDown = (e: React.KeyboardEvent) => { // Relevant only when initial values are both `''` and `showInput` is set if (this._isDirectionalKeyPress(e) && !this.isValid) { e.preventDefault(); this._resetToRangeEnds(e); } }; handleLowerInputChange = (e: React.ChangeEvent) => { this._handleOnChange(e.target.value, this.upperValue, e); }; handleUpperInputChange = (e: React.ChangeEvent) => { this._handleOnChange(this.lowerValue, e.target.value, e); }; _handleKeyDown = ( value: ValueMember, event: | React.KeyboardEvent | React.KeyboardEvent ) => { let newVal = Number(value); let stepRemainder = 0; const step = this.props.step || 1; switch (event.key) { case keys.ARROW_UP: case keys.ARROW_RIGHT: event.preventDefault(); newVal += step; stepRemainder = (newVal - this.props.min) % step; if (step !== 1 && stepRemainder > 0) { newVal = newVal - stepRemainder; } break; case keys.ARROW_DOWN: case keys.ARROW_LEFT: event.preventDefault(); newVal -= step; stepRemainder = (newVal - this.props.min) % step; if (step !== 1 && stepRemainder > 0) { newVal = newVal + (step - stepRemainder); } break; } return newVal; }; handleLowerKeyDown = (event: React.KeyboardEvent) => { let lower = this.lowerValue; switch (event.key) { case keys.TAB: return; default: if (!this.lowerValueIsValid) { // Relevant only when initial value is `''` and `showInput` is not set event.preventDefault(); this._resetToRangeEnds(event); return; } lower = this._handleKeyDown(lower, event); } if (lower >= this.upperValue || lower < this.props.min) return; this._handleOnChange(lower, this.upperValue, event); }; handleUpperKeyDown = (event: React.KeyboardEvent) => { let upper = this.upperValue; switch (event.key) { case keys.TAB: return; default: if (!this.upperValueIsValid) { // Relevant only when initial value is `''` and `showInput` is not set event.preventDefault(); this._resetToRangeEnds(event); return; } upper = this._handleKeyDown(upper, event); } if (upper <= this.lowerValue || upper > this.props.max) return; this._handleOnChange(this.lowerValue, upper, event); }; handleDraggableKeyDown = (event: React.KeyboardEvent) => { let lower = this.lowerValue; let upper = this.upperValue; switch (event.key) { case keys.TAB: return; default: lower = this._handleKeyDown(lower, event); upper = this._handleKeyDown(upper, event); } if (lower >= this.upperValue || lower < this.props.min) return; if (upper <= this.lowerValue || upper > this.props.max) return; this._handleOnChange(lower, upper, event); }; calculateThumbPositionStyle = (value: number, width?: number) => { const trackWidth = this.props.showInput === 'inputWithPopover' && !!width ? width : this.rangeSliderRef!.clientWidth; const position = calculateThumbPosition( value, this.props.min, this.props.max, trackWidth ); return { left: `${position}%` }; }; toggleHasFocus = (shouldFocused = !this.state.hasFocus) => { this.setState({ hasFocus: shouldFocused, }); }; onThumbFocus = (e: React.FocusEvent) => { if (this.props.onFocus) { this.props.onFocus(e); } this.toggleHasFocus(true); }; onThumbBlur = (e: React.FocusEvent) => { if (this.props.onBlur) { this.props.onBlur(e); } this.toggleHasFocus(false); }; onInputFocus = (e: React.FocusEvent) => { if (this.props.onFocus) { this.props.onFocus(e); } this.preventPopoverClose = true; this.setState({ isPopoverOpen: true, }); }; onInputBlur = (e: React.FocusEvent) => setTimeout(() => { // Safari does not recognize any focus-related eventing for input[type=range] // making it impossible to capture its state using active/focus/relatedTarget // Instead, a prevention flag is set on mousedown, with a waiting period here. // Mousedown is viable because in the popover case, it is inaccessible via keyboard (intentionally) if (this.preventPopoverClose) { this.preventPopoverClose = false; return; } if (this.props.onBlur) { this.props.onBlur(e); } this.closePopover(); }, 200); closePopover = () => { this.preventPopoverClose = false; this.setState({ isPopoverOpen: false, }); }; onResize = (width?: number) => { this.setState({ rangeWidth: width, }); }; getNearestStep = (value: number) => { const steps = (this.props.max - this.props.min) / this.props.step!; const approx = Math.round( ((value - this.props.min) * steps) / (this.props.max - this.props.min) ) / steps; const bound = Math.min(Math.max(approx, 0), 1); const nearest = bound * (this.props.max - this.props.min) + this.props.min; return (Number(nearest.toPrecision(10)) * 100) / 100; }; handleDrag = (x: number, isFirstInteraction?: boolean) => { if (isFirstInteraction) { this.leftPosition = x; this.dragAcc = 0; } const { min, max } = this.props; const lowerValue = Number(this.lowerValue); const upperValue = Number(this.upperValue); const delta = this.leftPosition - x; this.leftPosition = x; this.dragAcc = this.dragAcc + delta; const percentageOfArea = this.dragAcc / this.rangeSliderRef!.clientWidth; const percentageOfRange = percentageOfArea * (max - min); const newLower = this.getNearestStep(lowerValue - percentageOfRange); const newUpper = this.getNearestStep(upperValue - percentageOfRange); const noMovement = newLower === lowerValue; const isMin = min === lowerValue && min === newLower; const isMax = max === upperValue && max === newUpper; const isOutOfRange = newLower < min || newUpper > max; if (noMovement || isMin || isMax || isOutOfRange) return; this._handleOnChange(newLower, newUpper); this.dragAcc = 0; }; render() { const { className, compressed, disabled, fullWidth, readOnly, id: propsId, max, min, name, step, showLabels, showInput, showTicks, tickInterval, ticks, levels, onBlur, onChange, onFocus, showRange, value, style, isInvalid, append, prepend, minInputProps, maxInputProps, isDraggable, ...rest } = this.props; const { id } = this.state; const digitTolerance = Math.max(String(min).length, String(max).length); const showInputOnly = showInput === 'inputWithPopover'; const canShowDropdown = showInputOnly && !readOnly && !disabled; const minInput = !!showInput ? ( (this.preventPopoverClose = true) : undefined } /> ) : undefined; const maxInput = !!showInput ? ( (this.preventPopoverClose = true) : undefined } /> ) : undefined; const classes = classNames('ouiDualRange', className); const leftThumbPosition = this.state.rangeSliderRefAvailable ? this.calculateThumbPositionStyle( Number(this.lowerValue) || min, this.state.rangeWidth ) : { left: '0' }; const rightThumbPosition = this.state.rangeSliderRefAvailable ? this.calculateThumbPositionStyle( Number(this.upperValue) || max, this.state.rangeWidth ) : { left: '0' }; const theRange = ( {showInput && !showInputOnly && ( <> {minInput}
)} {showLabels && ( {min} )} {showRange && this.isValid && ( )} {this.state.rangeSliderRefAvailable && ( {isDraggable && this.isValid && ( )} )} {showLabels && {max}} {showInput && !showInputOnly && ( <>
{maxInput} )} ); const thePopover = showInputOnly ? ( } fullWidth={fullWidth} isOpen={this.state.isPopoverOpen} closePopover={this.closePopover} disableFocusTrap={true} onPanelResize={this.onResize}> {theRange} ) : undefined; return thePopover || theRange; } }