New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix(Slider): onRelease not always firing (#2695) #5359
Changes from 2 commits
a5498b4
f3e84ba
cb04ebf
2545e7a
2f7054f
dd3f739
57827fd
c38670e
3600eb8
39e34c3
dcae226
34bde1f
6729a2b
7c6a641
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -17,6 +17,38 @@ const defaultFormatLabel = (value, label) => { | |
return typeof label === 'function' ? label(value) : `${value}${label}`; | ||
}; | ||
|
||
/** | ||
* Types of events used to indicate that the slider's value should get recalculated. | ||
*/ | ||
const CALC_VALUE_EVENT_TYPES = Object.freeze([ | ||
'mousemove', | ||
'mousedown', | ||
'click', | ||
'touchmove', | ||
'touchstart', | ||
]); | ||
|
||
/** | ||
* Notes on Slider event handling: | ||
* - The Slider handles 6 types of events. Five of them are defined in the `CALC_VALUE_EVENT_TYPES` | ||
* array, while the last one, `mouseup`, is added on-the-fly in response to a `mousedown` event. | ||
* 'mouseup' will remove itself as an event listener when fired. | ||
* | ||
* - All of the event handlers eventually drop into `this.updatePosition`. | ||
* | ||
* - `this.updatePosition` serves 3 main roles: 1) Calculating a new value and thumb position; 2) | ||
* Updating the state with the new values; 3) Requesting an animation frame from the browser, | ||
* during which the `onChange` and `onRelease` callbacks are potentially called. | ||
* | ||
* - `requestAnimationFrame` is used to rate-limit the number of events that are sent back to the | ||
* component user as the slider changes. | ||
* | ||
* - As the value/thumb position are updated, `this.state.needsOnChange` and | ||
* `this.state.needsOnRelease` may be set to true, indicating that during the next requested | ||
* animation frame, the `onChange` and/or `onRelease` callbacks should be called. | ||
* - Events fired with types other than those listed in `CALC_VALUE_EVENT_TYPES` will not result in | ||
* the value/thumb position being recalculated. | ||
*/ | ||
export default class Slider extends PureComponent { | ||
static propTypes = { | ||
/** | ||
|
@@ -143,10 +175,11 @@ export default class Slider extends PureComponent { | |
}; | ||
|
||
state = { | ||
dragging: false, | ||
holding: false, | ||
value: this.props.value, | ||
left: 0, | ||
needsOnChange: false, | ||
needsOnRelease: false, | ||
}; | ||
|
||
static getDerivedStateFromProps({ value, min, max }, state) { | ||
|
@@ -176,41 +209,56 @@ export default class Slider extends PureComponent { | |
evt.persist(); | ||
} | ||
|
||
if (this.state.dragging) { | ||
return; | ||
} | ||
this.setState({ dragging: true }); | ||
|
||
this.handleDrag(); | ||
|
||
requestAnimationFrame(() => { | ||
this.setState((prevState, props) => { | ||
// Note: In FF, `evt.target` of `mousemove` event can be `HTMLDocument` which doesn't have `classList`. | ||
// One example is dragging out of browser viewport. | ||
const fromInput = | ||
evt && | ||
evt.target && | ||
evt.target.classList && | ||
evt.target.classList.contains('bx-slider-text-input'); | ||
const { left, newValue: newSliderValue } = this.calcValue( | ||
evt, | ||
prevState, | ||
props | ||
); | ||
const newValue = fromInput ? Number(evt.target.value) : newSliderValue; | ||
if (prevState.left === left && prevState.value === newValue) { | ||
return { dragging: false }; | ||
const setStateUpdater = (prevState, props) => { | ||
// Note: In FF, `evt.target` of `mousemove` event can be `HTMLDocument` which doesn't have | ||
// `classList`. One example is dragging out of browser viewport. | ||
const fromInput = | ||
evt && | ||
evt.target && | ||
evt.target.classList && | ||
evt.target.classList.contains('bx-slider-text-input'); | ||
|
||
const { left, newValue: newSliderValue } = this.calcValue( | ||
evt, | ||
prevState, | ||
props | ||
); | ||
|
||
const newValue = fromInput ? Number(evt.target.value) : newSliderValue; | ||
|
||
if (prevState.left === left && prevState.value === newValue) { | ||
return; | ||
} | ||
|
||
return { | ||
left, | ||
value: newValue, | ||
needsOnChange: true, | ||
}; | ||
}; | ||
|
||
const setStateCallback = () => { | ||
this.handleDragComplete(); | ||
|
||
requestAnimationFrame(() => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Seems like the call to rAF is moved from enqueuing the state update to now calling There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I did this primarily because the way rAF was being used was the cause of the bug, but also because it was hard to determine exactly why rAF was being used. I ran under the assumption that it was being used to rate limit the number of I got a message earlier from @asudoh indicating that the reason for this rAF usage was in fact to throttle the number of mousemove events that are considered for state updates, but in spite of this intent, I don't believe this is actually the way the component was behaving. Since pretty much every mousemove event ultimately resulted in a call to rAF, these events were simply being queued for batch execution at the next animation frame; none of them would actually be discarded from consideration altogether. In theory, the net number of state updates would be the same, except in the old implementation, the so I guess my question is: What is the Carbon-wide paradigm here? Are callbacks and events typically rate limited? If so, are there typical ways in which components accomplish this? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thank you for your thoughts @jdharvey-ibm! Basically, we use The current The direct cause of the problem is that running An indirect cause of the problem that such throttling logic is written in a way that may confuse people who try to jump in and maintain the code that the logic is for managing "dragging state" instead. That caused the one who introduced There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unfortunately making that particular change you suggest (I did indeed try that earlier on) regresses the deterministic behavior of the order in which the events are fired. Since If the long-term goal is to convert this to use There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
And you are right that the long-term goal is converting the code to use |
||
if (this.state.needsOnChange) { | ||
if (typeof this.props.onChange === 'function') { | ||
this.props.onChange({ value: this.state.value }); | ||
} | ||
} | ||
if (typeof props.onChange === 'function') { | ||
props.onChange({ value: newValue }); | ||
if (this.state.needsOnRelease) { | ||
if (typeof this.props.onRelease === 'function') { | ||
this.props.onRelease({ value: this.state.value }); | ||
} | ||
} | ||
return { | ||
dragging: false, | ||
left, | ||
value: newValue, | ||
}; | ||
this.setState({ | ||
needsOnChange: false, | ||
needsOnRelease: false, | ||
}); | ||
}); | ||
}); | ||
}; | ||
|
||
this.setState(setStateUpdater, setStateCallback); | ||
}; | ||
|
||
calcValue = (evt, prevState, props) => { | ||
|
@@ -248,7 +296,8 @@ export default class Slider extends PureComponent { | |
newValue = Number(value) + stepMultiplied * direction; | ||
} | ||
} | ||
if (type === 'mousemove' || type === 'click' || type === 'touchmove') { | ||
// If the event type indicates that we should update, then recalculate the value. | ||
if (CALC_VALUE_EVENT_TYPES.includes(type)) { | ||
const clientX = evt.touches ? evt.touches[0].clientX : evt.clientX; | ||
const track = this.track.getBoundingClientRect(); | ||
const ratio = (clientX - track.left) / track.width; | ||
|
@@ -270,10 +319,38 @@ export default class Slider extends PureComponent { | |
return { left, newValue }; | ||
}; | ||
|
||
handleMouseStart = () => { | ||
this.setState({ | ||
holding: true, | ||
}); | ||
/** | ||
* Check if dragging is done. If so, request an `onRelease` by setting `needsOnRelease` in the | ||
* component state. | ||
*/ | ||
handleDragComplete = () => { | ||
if ( | ||
typeof this.props.onRelease === 'function' && | ||
!this.props.disabled && | ||
!this.state.holding | ||
) { | ||
this.setState({ | ||
needsOnRelease: true, | ||
}); | ||
} | ||
}; | ||
|
||
handleMouseStart = evt => { | ||
if (!evt) { | ||
return; | ||
} else { | ||
// Persist the synthetic event so it can be accessed below in setState | ||
evt.persist(); | ||
} | ||
|
||
this.setState( | ||
{ | ||
holding: true, | ||
}, | ||
() => { | ||
this.updatePosition(evt); | ||
} | ||
); | ||
|
||
this.element.ownerDocument.addEventListener( | ||
'mousemove', | ||
|
@@ -301,9 +378,12 @@ export default class Slider extends PureComponent { | |
}; | ||
|
||
handleTouchStart = () => { | ||
this.setState({ | ||
holding: true, | ||
}); | ||
this.setState( | ||
{ | ||
holding: true, | ||
}, | ||
this.updatePosition | ||
); | ||
this.element.ownerDocument.addEventListener( | ||
'touchmove', | ||
this.updatePosition | ||
|
@@ -350,16 +430,6 @@ export default class Slider extends PureComponent { | |
this.updatePosition(evt); | ||
}; | ||
|
||
handleDrag = () => { | ||
if ( | ||
typeof this.props.onRelease === 'function' && | ||
!this.props.disabled && | ||
!this.state.holding | ||
) { | ||
this.props.onRelease({ value: this.state.value }); | ||
} | ||
}; | ||
|
||
render() { | ||
const { | ||
ariaLabelInput, | ||
|
@@ -433,6 +503,9 @@ export default class Slider extends PureComponent { | |
}} | ||
onClick={this.updatePosition} | ||
onKeyPress={this.updatePosition} | ||
onMouseDown={this.handleMouseStart} | ||
onTouchStart={this.handleTouchStart} | ||
onKeyDown={this.updatePosition} | ||
role="presentation" | ||
tabIndex={-1} | ||
{...other}> | ||
|
@@ -445,9 +518,6 @@ export default class Slider extends PureComponent { | |
aria-valuemin={min} | ||
aria-valuenow={value} | ||
style={thumbStyle} | ||
onMouseDown={this.handleMouseStart} | ||
onTouchStart={this.handleTouchStart} | ||
onKeyDown={this.updatePosition} | ||
/> | ||
<div | ||
className={`${prefix}--slider__track`} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nitpick - Probably a
Set
?