index.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407
  1. import {forwardRef, useCallback, useImperativeHandle, useMemo, useRef} from 'react';
  2. import isPropValid from '@emotion/is-prop-valid';
  3. import styled from '@emotion/styled';
  4. import {useNumberFormatter} from '@react-aria/i18n';
  5. import {AriaSliderProps, AriaSliderThumbOptions, useSlider} from '@react-aria/slider';
  6. import {useSliderState} from '@react-stately/slider';
  7. import {Tooltip} from 'sentry/components/tooltip';
  8. import {space} from 'sentry/styles/space';
  9. import {SliderThumb} from './thumb';
  10. export interface SliderProps
  11. extends Omit<AriaSliderProps, 'minValue' | 'maxValue' | 'isDisabled'>,
  12. Pick<AriaSliderThumbOptions, 'autoFocus' | 'onFocus' | 'onBlur' | 'onFocusChange'> {
  13. /**
  14. * (This prop is now deprecated - slider ranges need to have consistent, evenly
  15. * spaced values. Use `min`/`max`/`step` instead.)
  16. *
  17. * Custom array of selectable values on the track. If specified, the `min`/`max`/`step`
  18. * props will be ignored. Make sure the array is sorted.
  19. * @deprecated
  20. */
  21. allowedValues?: number[];
  22. className?: string;
  23. disabled?: boolean;
  24. disabledReason?: React.ReactNode;
  25. error?: boolean;
  26. /**
  27. * Apply custom formatting to output/tick labels. If only units are needed, use the
  28. * `formatOptions` prop instead.
  29. */
  30. formatLabel?: (value: number | '') => React.ReactNode;
  31. formatOptions?: Intl.NumberFormatOptions;
  32. max?: AriaSliderProps['maxValue'];
  33. min?: AriaSliderProps['minValue'];
  34. required?: boolean;
  35. /**
  36. * Whether to show value labels above the slider's thumbs. Note: if `label` is defined,
  37. * then thumb labels will be hidden in favor of the trailing output label.
  38. */
  39. showThumbLabels?: boolean;
  40. /**
  41. * Whether to show labels below track ticks.
  42. */
  43. showTickLabels?: boolean;
  44. /**
  45. * The values to display tick marks at, e.g. [2, 4] means there will be ticks at 2 & 4.
  46. *
  47. * See also: ticks, ticksInterval. The order of precedence is: ticks — ticksInterval —
  48. * tickValues. E.g. if tickValues is defined, both ticks & ticksEvery will be ignored.
  49. */
  50. tickValues?: number[];
  51. /**
  52. * Number of tick marks (including the outer min/max ticks) to display on the track.
  53. *
  54. * See also: ticksInterval, tickValues. The order of precedence is: ticks —
  55. * ticksInterval — tickValues. E.g. if tickValues is defined, both ticks & ticksEvery
  56. * will be ignored.
  57. */
  58. ticks?: number;
  59. /**
  60. * Interval between tick marks. This number should evenly divide the slider's range.
  61. *
  62. * See also: ticks, tickValues. The order of precedence is: ticks — ticksInterval —
  63. * tickValues. E.g. if tickValues is defined, both ticks & ticksEvery will be ignored.
  64. */
  65. ticksInterval?: number;
  66. }
  67. function BaseSlider(
  68. {
  69. // Slider/track props
  70. min = 0,
  71. max = 100,
  72. step = 1,
  73. disabled = false,
  74. disabledReason,
  75. error = false,
  76. required = false,
  77. ticks,
  78. ticksInterval,
  79. tickValues,
  80. showTickLabels = false,
  81. showThumbLabels = false,
  82. formatLabel,
  83. formatOptions,
  84. allowedValues,
  85. className,
  86. // Thumb props
  87. autoFocus,
  88. onFocus,
  89. onBlur,
  90. onFocusChange,
  91. ...props
  92. }: SliderProps,
  93. forwardedRef: React.ForwardedRef<HTMLInputElement | HTMLInputElement[]>
  94. ) {
  95. const {label, value, defaultValue, onChange, onChangeEnd} = props;
  96. const ariaProps: AriaSliderProps = {
  97. ...props,
  98. step,
  99. minValue: min,
  100. maxValue: max,
  101. isDisabled: disabled,
  102. // Backward compatibility support for `allowedValues` prop. Since range sliders only
  103. // accept evenly spaced values (specified with `min`/`max`/`step`), we need to create
  104. // a custom set of internal values that act as indices for the `allowedValues` array.
  105. // For example, if `allowedValues` is [1, 2, 4, 8], then the corresponding internal
  106. // values are [0, 1, 2, 3]. If the first value (index 0) is selected, then onChange()
  107. // will be called with `onChange(allowedValues[0])`, i.e. `onChange(1)`.
  108. ...(allowedValues && {
  109. minValue: 0,
  110. maxValue: allowedValues.length - 1,
  111. step: 1,
  112. value: Array.isArray(value)
  113. ? value.map(allowedValues.indexOf)
  114. : allowedValues.indexOf(value ?? 0),
  115. defaultValue: Array.isArray(defaultValue)
  116. ? defaultValue.map(allowedValues.indexOf)
  117. : allowedValues.indexOf(defaultValue ?? 0),
  118. onChange: indexValue =>
  119. onChange?.(
  120. Array.isArray(indexValue)
  121. ? indexValue.map(i => allowedValues[i])
  122. : allowedValues[indexValue]
  123. ),
  124. onChangeEnd: indexValue =>
  125. onChangeEnd?.(
  126. Array.isArray(indexValue)
  127. ? indexValue.map(i => allowedValues[i])
  128. : allowedValues[indexValue]
  129. ),
  130. }),
  131. };
  132. const trackRef = useRef<HTMLDivElement>(null);
  133. const numberFormatter = useNumberFormatter(formatOptions);
  134. const state = useSliderState({...ariaProps, numberFormatter});
  135. const {groupProps, trackProps, labelProps, outputProps} = useSlider(
  136. ariaProps,
  137. state,
  138. trackRef
  139. );
  140. const allTickValues = useMemo(() => {
  141. if (tickValues) {
  142. return tickValues;
  143. }
  144. if (ticksInterval) {
  145. const result: number[] = [];
  146. let current = min;
  147. while (current <= max) {
  148. result.push(current);
  149. current += ticksInterval;
  150. }
  151. return result.concat([max]);
  152. }
  153. if (ticks) {
  154. const range = max - min;
  155. return [...new Array(ticks)].map((_, i) => min + i * (range / (ticks - 1)));
  156. }
  157. return [];
  158. }, [ticks, ticksInterval, tickValues, min, max]);
  159. const nThumbs = state.values.length;
  160. const refs = useRef<Array<HTMLInputElement>>([]);
  161. useImperativeHandle(
  162. forwardedRef,
  163. () => {
  164. if (nThumbs > 1) {
  165. return refs.current;
  166. }
  167. return refs.current[0];
  168. },
  169. [nThumbs]
  170. );
  171. const getFormattedValue = useCallback(
  172. (val: number) => {
  173. // Special formatting when `allowedValues` is specified, in which case `val` acts
  174. // like an index for `allowedValues`.
  175. if (allowedValues) {
  176. return formatLabel
  177. ? formatLabel(allowedValues[val])
  178. : state.getFormattedValue(allowedValues[val]);
  179. }
  180. return formatLabel ? formatLabel(val) : state.getFormattedValue(val);
  181. },
  182. [formatLabel, state, allowedValues]
  183. );
  184. const selectedRange =
  185. nThumbs > 1
  186. ? [Math.min(...state.values), Math.max(...state.values)]
  187. : [min, state.values[0]];
  188. return (
  189. <Tooltip
  190. title={disabledReason}
  191. disabled={!disabled}
  192. skipWrapper
  193. isHoverable
  194. position="bottom"
  195. offset={-15}
  196. >
  197. <SliderGroup {...groupProps} className={className}>
  198. {label && (
  199. <SliderLabelWrapper className="label-container">
  200. <SliderLabel {...labelProps}>{label}</SliderLabel>
  201. <SliderLabelOutput {...outputProps}>
  202. {nThumbs > 1
  203. ? `${getFormattedValue(selectedRange[0])}–${getFormattedValue(
  204. selectedRange[1]
  205. )}`
  206. : getFormattedValue(selectedRange[1])}
  207. </SliderLabelOutput>
  208. </SliderLabelWrapper>
  209. )}
  210. <SliderTrack
  211. ref={trackRef}
  212. {...trackProps}
  213. disabled={disabled}
  214. hasThumbLabels={showThumbLabels && !label}
  215. hasTickLabels={showTickLabels && allTickValues.length > 0}
  216. >
  217. <SliderLowerTrack
  218. role="presentation"
  219. disabled={disabled}
  220. error={error}
  221. style={{
  222. left: `${state.getValuePercent(selectedRange[0]) * 100}%`,
  223. right: `${100 - state.getValuePercent(selectedRange[1]) * 100}%`,
  224. }}
  225. />
  226. {allTickValues.map((tickValue, index) => (
  227. <SliderTick
  228. key={tickValue}
  229. aria-hidden
  230. error={error}
  231. disabled={disabled}
  232. inSelection={tickValue >= selectedRange[0] && tickValue <= selectedRange[1]}
  233. style={{left: `${(state.getValuePercent(tickValue) * 100).toFixed(2)}%`}}
  234. justifyContent={
  235. index === 0
  236. ? 'start'
  237. : index === allTickValues.length - 1
  238. ? 'end'
  239. : 'center'
  240. }
  241. >
  242. {showTickLabels && (
  243. <SliderTickLabel>{getFormattedValue(tickValue)}</SliderTickLabel>
  244. )}
  245. </SliderTick>
  246. ))}
  247. {[...new Array(nThumbs)].map((_, index) => (
  248. <SliderThumb
  249. ref={node => {
  250. if (!node) {
  251. return;
  252. }
  253. refs.current = [
  254. ...refs.current.slice(0, index),
  255. node,
  256. ...refs.current.slice(index + 1),
  257. ];
  258. }}
  259. key={index}
  260. index={index}
  261. state={state}
  262. trackRef={trackRef}
  263. isDisabled={disabled}
  264. showLabel={showThumbLabels && !label}
  265. getFormattedValue={getFormattedValue}
  266. isRequired={required}
  267. autoFocus={autoFocus && index === 0}
  268. onFocus={onFocus}
  269. onBlur={onBlur}
  270. onFocusChange={onFocusChange}
  271. error={error}
  272. />
  273. ))}
  274. </SliderTrack>
  275. </SliderGroup>
  276. </Tooltip>
  277. );
  278. }
  279. const Slider = forwardRef(BaseSlider);
  280. export {Slider};
  281. const SliderGroup = styled('div')`
  282. width: 100%;
  283. display: flex;
  284. flex-direction: column;
  285. justify-content: center;
  286. white-space: nowrap;
  287. `;
  288. const SliderLabelWrapper = styled('div')`
  289. display: flex;
  290. justify-content: space-between;
  291. margin-bottom: ${space(1.5)};
  292. `;
  293. const SliderLabel = styled('label')`
  294. font-weight: 400;
  295. color: ${p => p.theme.textColor};
  296. `;
  297. const SliderLabelOutput = styled('output')`
  298. margin: 0;
  299. padding: 0;
  300. font-variant-numeric: tabular-nums;
  301. color: ${p => p.theme.subText};
  302. `;
  303. const SliderTrack = styled('div', {
  304. shouldForwardProp: prop =>
  305. prop !== 'disabled' && typeof prop === 'string' && isPropValid(prop),
  306. })<{
  307. disabled: boolean;
  308. hasThumbLabels: boolean;
  309. hasTickLabels: boolean;
  310. }>`
  311. position: relative;
  312. width: calc(100% - 2px);
  313. height: 3px;
  314. border-radius: 3px;
  315. background: ${p => p.theme.border};
  316. margin-left: 1px; /* to better align track with label */
  317. margin-bottom: ${p => (p.hasTickLabels ? '2em' : '0.5rem')};
  318. margin-top: ${p => (p.hasThumbLabels ? '2em' : '0.5rem')};
  319. ${p => p.disabled && `pointer-events: none;`}
  320. /* Users can click on the track to quickly jump to a value. We should extend the click
  321. area to make the action easier. */
  322. &::before {
  323. content: '';
  324. width: 100%;
  325. height: 1.5rem;
  326. border-radius: 50%;
  327. position: absolute;
  328. top: 50%;
  329. left: 50%;
  330. transform: translate(-50%, -50%);
  331. }
  332. `;
  333. const SliderLowerTrack = styled('div')<{disabled: boolean; error: boolean}>`
  334. position: absolute;
  335. height: inherit;
  336. border-radius: inherit;
  337. background: ${p => p.theme.active};
  338. pointer-events: none;
  339. ${p => p.error && `background: ${p.theme.error};`}
  340. ${p => p.disabled && `background: ${p.theme.subText};`}
  341. `;
  342. const SliderTick = styled('div')<{
  343. disabled: boolean;
  344. error: boolean;
  345. inSelection: boolean;
  346. justifyContent: string;
  347. }>`
  348. display: flex;
  349. justify-content: ${p => p.justifyContent};
  350. position: absolute;
  351. top: 50%;
  352. transform: translate(-50%, -50%);
  353. width: 2px;
  354. height: 6px;
  355. border-radius: 2px;
  356. background: ${p => p.theme.translucentBorder};
  357. ${p =>
  358. p.inSelection &&
  359. `background: ${
  360. p.disabled ? p.theme.subText : p.error ? p.theme.error : p.theme.active
  361. };`}
  362. `;
  363. const SliderTickLabel = styled('small')`
  364. display: inline-block;
  365. position: absolute;
  366. top: calc(100% + ${space(1)});
  367. margin: 0 -1px;
  368. color: ${p => p.theme.subText};
  369. font-size: ${p => p.theme.fontSizeSmall};
  370. `;