UNPKG

10.9 kBTypeScriptView Raw
1/*
2 * Copyright 2016 Palantir Technologies, Inc. All rights reserved.
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17import classNames from "classnames";
18import * as React from "react";
19import { polyfill } from "react-lifecycles-compat";
20
21import { AbstractPureComponent2, Classes, Keys } from "../../common";
22import { DISPLAYNAME_PREFIX } from "../../common/props";
23import { clamp } from "../../common/utils";
24import { HandleProps } from "./handleProps";
25import { formatPercentage } from "./sliderUtils";
26
27/**
28 * Props for the internal <Handle> component needs some additional info from the parent Slider.
29 */
30export interface IInternalHandleProps extends HandleProps {
31 disabled?: boolean;
32 label: JSX.Element | string | undefined;
33 max: number;
34 min: number;
35 stepSize: number;
36 tickSize: number;
37 tickSizeRatio: number;
38 vertical: boolean;
39}
40
41export interface IHandleState {
42 /** whether slider handle is currently being dragged */
43 isMoving?: boolean;
44}
45
46// props that require number values, for validation
47const NUMBER_PROPS = ["max", "min", "stepSize", "tickSize", "value"];
48
49/** Internal component for a Handle with click/drag/keyboard logic to determine a new value. */
50@polyfill
51export class Handle extends AbstractPureComponent2<IInternalHandleProps, IHandleState> {
52 public static displayName = `${DISPLAYNAME_PREFIX}.SliderHandle`;
53
54 public state = {
55 isMoving: false,
56 };
57
58 private handleElement: HTMLElement | null = null;
59
60 private refHandlers = {
61 handle: (el: HTMLSpanElement) => (this.handleElement = el),
62 };
63
64 public componentDidMount() {
65 // The first time this component renders, it has no ref to the handle and thus incorrectly centers the handle.
66 // Therefore, on the first mount, force a re-render to center the handle with the ref'd component.
67 this.forceUpdate();
68 }
69
70 public render() {
71 const { className, disabled, label } = this.props;
72 const { isMoving } = this.state;
73
74 return (
75 <span
76 className={classNames(Classes.SLIDER_HANDLE, { [Classes.ACTIVE]: isMoving }, className)}
77 onKeyDown={disabled ? undefined : this.handleKeyDown}
78 onKeyUp={disabled ? undefined : this.handleKeyUp}
79 onMouseDown={disabled ? undefined : this.beginHandleMovement}
80 onTouchStart={disabled ? undefined : this.beginHandleTouchMovement}
81 ref={this.refHandlers.handle}
82 style={this.getStyleProperties()}
83 tabIndex={0}
84 >
85 {label == null ? null : <span className={Classes.SLIDER_LABEL}>{label}</span>}
86 </span>
87 );
88 }
89
90 public componentWillUnmount() {
91 this.removeDocumentEventListeners();
92 }
93
94 /** Convert client pixel to value between min and max. */
95 public clientToValue(clientPixel: number) {
96 const { stepSize, tickSize, value, vertical } = this.props;
97 if (this.handleElement == null) {
98 return value;
99 }
100
101 // #1769: this logic doesn't work perfectly when the tick size is
102 // smaller than the handle size; it may be off by a tick or two.
103 const clientPixelNormalized = vertical ? window.innerHeight - clientPixel : clientPixel;
104 const handleCenterPixel = this.getHandleElementCenterPixel(this.handleElement);
105 const pixelDelta = clientPixelNormalized - handleCenterPixel;
106
107 if (isNaN(pixelDelta)) {
108 return value;
109 }
110 // convert pixels to range value in increments of `stepSize`
111 return value + Math.round(pixelDelta / (tickSize * stepSize)) * stepSize;
112 }
113
114 public mouseEventClientOffset(event: MouseEvent | React.MouseEvent<HTMLElement>) {
115 return this.props.vertical ? event.clientY : event.clientX;
116 }
117
118 public touchEventClientOffset(event: TouchEvent | React.TouchEvent<HTMLElement>) {
119 const touch = event.changedTouches[0];
120 return this.props.vertical ? touch.clientY : touch.clientX;
121 }
122
123 public beginHandleMovement = (event: MouseEvent | React.MouseEvent<HTMLElement>) => {
124 document.addEventListener("mousemove", this.handleHandleMovement);
125 document.addEventListener("mouseup", this.endHandleMovement);
126 this.setState({ isMoving: true });
127 this.changeValue(this.clientToValue(this.mouseEventClientOffset(event)));
128 };
129
130 public beginHandleTouchMovement = (event: TouchEvent | React.TouchEvent<HTMLElement>) => {
131 document.addEventListener("touchmove", this.handleHandleTouchMovement);
132 document.addEventListener("touchend", this.endHandleTouchMovement);
133 document.addEventListener("touchcancel", this.endHandleTouchMovement);
134 this.setState({ isMoving: true });
135 this.changeValue(this.clientToValue(this.touchEventClientOffset(event)));
136 };
137
138 protected validateProps(props: IInternalHandleProps) {
139 for (const prop of NUMBER_PROPS) {
140 if (typeof (props as any)[prop] !== "number") {
141 throw new Error(`[Blueprint] <Handle> requires number value for ${prop} prop`);
142 }
143 }
144 }
145
146 private getStyleProperties = (): React.CSSProperties => {
147 if (this.handleElement == null) {
148 return {};
149 }
150
151 // The handle midpoint of RangeSlider is actually shifted by a margin to
152 // be on the edge of the visible handle element. Because the midpoint
153 // calculation does not take this margin into account, we instead
154 // measure the long side (which is equal to the short side plus the
155 // margin).
156
157 const { min = 0, tickSizeRatio, value, vertical } = this.props;
158 const { handleMidpoint } = this.getHandleMidpointAndOffset(this.handleElement, true);
159 const offsetRatio = (value - min) * tickSizeRatio;
160 const offsetCalc = `calc(${formatPercentage(offsetRatio)} - ${handleMidpoint}px)`;
161 return vertical ? { bottom: offsetCalc } : { left: offsetCalc };
162 };
163
164 private endHandleMovement = (event: MouseEvent) => {
165 this.handleMoveEndedAt(this.mouseEventClientOffset(event));
166 };
167
168 private endHandleTouchMovement = (event: TouchEvent) => {
169 this.handleMoveEndedAt(this.touchEventClientOffset(event));
170 };
171
172 private handleMoveEndedAt = (clientPixel: number) => {
173 this.removeDocumentEventListeners();
174 this.setState({ isMoving: false });
175 // always invoke onRelease; changeValue may call onChange if value is different
176 const finalValue = this.changeValue(this.clientToValue(clientPixel));
177 this.props.onRelease?.(finalValue);
178 };
179
180 private handleHandleMovement = (event: MouseEvent) => {
181 this.handleMovedTo(this.mouseEventClientOffset(event));
182 };
183
184 private handleHandleTouchMovement = (event: TouchEvent) => {
185 this.handleMovedTo(this.touchEventClientOffset(event));
186 };
187
188 private handleMovedTo = (clientPixel: number) => {
189 if (this.state.isMoving && !this.props.disabled) {
190 this.changeValue(this.clientToValue(clientPixel));
191 }
192 };
193
194 private handleKeyDown = (event: React.KeyboardEvent<HTMLSpanElement>) => {
195 const { stepSize, value } = this.props;
196 // HACKHACK: https://github.com/palantir/blueprint/issues/4165
197 /* eslint-disable-next-line deprecation/deprecation */
198 const { which } = event;
199 if (which === Keys.ARROW_DOWN || which === Keys.ARROW_LEFT) {
200 this.changeValue(value - stepSize);
201 // this key event has been handled! prevent browser scroll on up/down
202 event.preventDefault();
203 } else if (which === Keys.ARROW_UP || which === Keys.ARROW_RIGHT) {
204 this.changeValue(value + stepSize);
205 event.preventDefault();
206 }
207 };
208
209 private handleKeyUp = (event: React.KeyboardEvent<HTMLSpanElement>) => {
210 // HACKHACK: https://github.com/palantir/blueprint/issues/4165
211 /* eslint-disable-next-line deprecation/deprecation */
212 if ([Keys.ARROW_UP, Keys.ARROW_DOWN, Keys.ARROW_LEFT, Keys.ARROW_RIGHT].indexOf(event.which) >= 0) {
213 this.props.onRelease?.(this.props.value);
214 }
215 };
216
217 /** Clamp value and invoke callback if it differs from current value */
218 private changeValue(newValue: number, callback = this.props.onChange) {
219 newValue = this.clamp(newValue);
220 if (!isNaN(newValue) && this.props.value !== newValue) {
221 callback?.(newValue);
222 }
223 return newValue;
224 }
225
226 /** Clamp value between min and max props */
227 private clamp(value: number) {
228 return clamp(value, this.props.min, this.props.max);
229 }
230
231 private getHandleElementCenterPixel(handleElement: HTMLElement) {
232 const { handleMidpoint, handleOffset } = this.getHandleMidpointAndOffset(handleElement);
233 return handleOffset + handleMidpoint;
234 }
235
236 private getHandleMidpointAndOffset(handleElement: HTMLElement, useOppositeDimension = false) {
237 if (handleElement == null) {
238 return { handleMidpoint: 0, handleOffset: 0 };
239 }
240
241 const { vertical } = this.props;
242
243 // getBoundingClientRect().height includes border size; clientHeight does not.
244 const handleRect = handleElement.getBoundingClientRect();
245
246 const sizeKey = vertical
247 ? useOppositeDimension
248 ? "width"
249 : "height"
250 : useOppositeDimension
251 ? "height"
252 : "width";
253
254 // "bottom" value seems to be consistently incorrect, so explicitly
255 // calculate it using the window offset instead.
256 const handleOffset = vertical ? window.innerHeight - (handleRect.top + handleRect[sizeKey]) : handleRect.left;
257
258 return { handleMidpoint: handleRect[sizeKey] / 2, handleOffset };
259 }
260
261 private removeDocumentEventListeners() {
262 document.removeEventListener("mousemove", this.handleHandleMovement);
263 document.removeEventListener("mouseup", this.endHandleMovement);
264 document.removeEventListener("touchmove", this.handleHandleTouchMovement);
265 document.removeEventListener("touchend", this.endHandleTouchMovement);
266 document.removeEventListener("touchcancel", this.endHandleTouchMovement);
267 }
268}