|
@@ -0,0 +1,394 @@
|
|
|
+import {forwardRef, useCallback, useImperativeHandle, useMemo, useRef} from 'react';
|
|
|
+import styled from '@emotion/styled';
|
|
|
+import {useNumberFormatter} from '@react-aria/i18n';
|
|
|
+import {AriaSliderProps, AriaSliderThumbOptions, useSlider} from '@react-aria/slider';
|
|
|
+import {useSliderState} from '@react-stately/slider';
|
|
|
+
|
|
|
+import {Tooltip} from 'sentry/components/tooltip';
|
|
|
+import {space} from 'sentry/styles/space';
|
|
|
+
|
|
|
+import {SliderThumb} from './thumb';
|
|
|
+
|
|
|
+export interface SliderProps
|
|
|
+ extends Omit<AriaSliderProps, 'minValue' | 'maxValue' | 'isDisabled'>,
|
|
|
+ Pick<AriaSliderThumbOptions, 'autoFocus' | 'onFocus' | 'onBlur' | 'onFocusChange'> {
|
|
|
+ /**
|
|
|
+ * (This prop is now deprecated - slider ranges need to have consistent, evenly
|
|
|
+ * spaced values. Use `min`/`max`/`step` instead.)
|
|
|
+ *
|
|
|
+ * Custom array of selectable values on the track. If specified, the `min`/`max`/`step`
|
|
|
+ * props will be ignored. Make sure the array is sorted.
|
|
|
+ * @deprecated
|
|
|
+ */
|
|
|
+ allowedValues?: number[];
|
|
|
+ className?: string;
|
|
|
+ disabled?: boolean;
|
|
|
+ disabledReason?: React.ReactNode;
|
|
|
+ error?: boolean;
|
|
|
+ /**
|
|
|
+ * Apply custom formatting to output/tick labels. If only units are needed, use the
|
|
|
+ * `formatOptions` prop instead.
|
|
|
+ */
|
|
|
+ formatLabel?: (value: number | '') => React.ReactNode;
|
|
|
+ formatOptions?: Intl.NumberFormatOptions;
|
|
|
+ max?: AriaSliderProps['maxValue'];
|
|
|
+ min?: AriaSliderProps['minValue'];
|
|
|
+ required?: boolean;
|
|
|
+ /**
|
|
|
+ * Whether to show value labels above the slider's thumbs. Note: if `label` is defined,
|
|
|
+ * then thumb labels will be hidden in favor of the trailing output label.
|
|
|
+ */
|
|
|
+ showThumbLabels?: boolean;
|
|
|
+ /**
|
|
|
+ * Whether to show labels below track ticks.
|
|
|
+ */
|
|
|
+ showTickLabels?: boolean;
|
|
|
+ /**
|
|
|
+ * The values to display tick marks at, e.g. [2, 4] means there will be ticks at 2 & 4.
|
|
|
+ *
|
|
|
+ * See also: ticks, ticksInterval. The order of precedence is: ticks — ticksInterval —
|
|
|
+ * tickValues. E.g. if tickValues is defined, both ticks & ticksEvery will be ignored.
|
|
|
+ */
|
|
|
+ tickValues?: number[];
|
|
|
+ /**
|
|
|
+ * Number of tick marks (including the outer min/max ticks) to display on the track.
|
|
|
+ *
|
|
|
+ * See also: ticksInterval, tickValues. The order of precedence is: ticks —
|
|
|
+ * ticksInterval — tickValues. E.g. if tickValues is defined, both ticks & ticksEvery
|
|
|
+ * will be ignored.
|
|
|
+ */
|
|
|
+ ticks?: number;
|
|
|
+ /**
|
|
|
+ * Interval between tick marks. This number should evenly divide the slider's range.
|
|
|
+ *
|
|
|
+ * See also: ticks, tickValues. The order of precedence is: ticks — ticksInterval —
|
|
|
+ * tickValues. E.g. if tickValues is defined, both ticks & ticksEvery will be ignored.
|
|
|
+ */
|
|
|
+ ticksInterval?: number;
|
|
|
+}
|
|
|
+
|
|
|
+function BaseSlider(
|
|
|
+ {
|
|
|
+ // Slider/track props
|
|
|
+ min = 0,
|
|
|
+ max = 100,
|
|
|
+ step = 1,
|
|
|
+ disabled = false,
|
|
|
+ disabledReason,
|
|
|
+ error = false,
|
|
|
+ required = false,
|
|
|
+ ticks,
|
|
|
+ ticksInterval,
|
|
|
+ tickValues,
|
|
|
+ showTickLabels = false,
|
|
|
+ showThumbLabels = false,
|
|
|
+ formatLabel,
|
|
|
+ formatOptions,
|
|
|
+ allowedValues,
|
|
|
+ className,
|
|
|
+
|
|
|
+ // Thumb props
|
|
|
+ autoFocus,
|
|
|
+ onFocus,
|
|
|
+ onBlur,
|
|
|
+ onFocusChange,
|
|
|
+
|
|
|
+ ...props
|
|
|
+ }: SliderProps,
|
|
|
+ forwardedRef: React.ForwardedRef<HTMLInputElement | HTMLInputElement[]>
|
|
|
+) {
|
|
|
+ const {label, value, defaultValue, onChange, onChangeEnd} = props;
|
|
|
+ const ariaProps: AriaSliderProps = {
|
|
|
+ ...props,
|
|
|
+ step,
|
|
|
+ minValue: min,
|
|
|
+ maxValue: max,
|
|
|
+ isDisabled: disabled,
|
|
|
+ // Backward compatibility support for `allowedValues` prop. Since range sliders only
|
|
|
+ // accept evenly spaced values (specified with `min`/`max`/`step`), we need to create
|
|
|
+ // a custom set of internal values that act as indices for the `allowedValues` array.
|
|
|
+ // For example, if `allowedValues` is [1, 2, 4, 8], then the corresponding internal
|
|
|
+ // values are [0, 1, 2, 3]. If the first value (index 0) is selected, then onChange()
|
|
|
+ // will be called with `onChange(allowedValues[0])`, i.e. `onChange(1)`.
|
|
|
+ ...(allowedValues && {
|
|
|
+ minValue: 0,
|
|
|
+ maxValue: allowedValues.length - 1,
|
|
|
+ step: 1,
|
|
|
+ value: Array.isArray(value)
|
|
|
+ ? value.map(allowedValues.indexOf)
|
|
|
+ : allowedValues.indexOf(value ?? 0),
|
|
|
+ defaultValue: Array.isArray(defaultValue)
|
|
|
+ ? defaultValue.map(allowedValues.indexOf)
|
|
|
+ : allowedValues.indexOf(defaultValue ?? 0),
|
|
|
+ onChange: indexValue =>
|
|
|
+ onChange?.(
|
|
|
+ Array.isArray(indexValue)
|
|
|
+ ? indexValue.map(i => allowedValues[i])
|
|
|
+ : allowedValues[indexValue]
|
|
|
+ ),
|
|
|
+ onChangeEnd: indexValue =>
|
|
|
+ onChangeEnd?.(
|
|
|
+ Array.isArray(indexValue)
|
|
|
+ ? indexValue.map(i => allowedValues[i])
|
|
|
+ : allowedValues[indexValue]
|
|
|
+ ),
|
|
|
+ }),
|
|
|
+ };
|
|
|
+
|
|
|
+ const trackRef = useRef<HTMLDivElement>(null);
|
|
|
+ const numberFormatter = useNumberFormatter(formatOptions);
|
|
|
+ const state = useSliderState({...ariaProps, numberFormatter});
|
|
|
+
|
|
|
+ const {groupProps, trackProps, labelProps, outputProps} = useSlider(
|
|
|
+ ariaProps,
|
|
|
+ state,
|
|
|
+ trackRef
|
|
|
+ );
|
|
|
+
|
|
|
+ const allTickValues = useMemo(() => {
|
|
|
+ if (tickValues) {
|
|
|
+ return tickValues;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (ticksInterval) {
|
|
|
+ const result: number[] = [];
|
|
|
+ let current = min;
|
|
|
+ while (current <= max) {
|
|
|
+ result.push(current);
|
|
|
+ current += ticksInterval;
|
|
|
+ }
|
|
|
+ return result.concat([max]);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (ticks) {
|
|
|
+ const range = max - min;
|
|
|
+ return [...new Array(ticks)].map((_, i) => min + i * (range / (ticks - 1)));
|
|
|
+ }
|
|
|
+
|
|
|
+ return [];
|
|
|
+ }, [ticks, ticksInterval, tickValues, min, max]);
|
|
|
+
|
|
|
+ const nThumbs = state.values.length;
|
|
|
+ const refs = useRef<Array<HTMLInputElement>>([]);
|
|
|
+ useImperativeHandle(
|
|
|
+ forwardedRef,
|
|
|
+ () => {
|
|
|
+ if (nThumbs > 1) {
|
|
|
+ return refs.current;
|
|
|
+ }
|
|
|
+ return refs.current[0];
|
|
|
+ },
|
|
|
+ [nThumbs]
|
|
|
+ );
|
|
|
+
|
|
|
+ const getFormattedValue = useCallback(
|
|
|
+ (val: number) => {
|
|
|
+ // Special formatting when `allowedValues` is specified, in which case `val` acts
|
|
|
+ // like an index for `allowedValues`.
|
|
|
+ if (allowedValues) {
|
|
|
+ return formatLabel
|
|
|
+ ? formatLabel(allowedValues[val])
|
|
|
+ : state.getFormattedValue(allowedValues[val]);
|
|
|
+ }
|
|
|
+
|
|
|
+ return formatLabel ? formatLabel(val) : state.getFormattedValue(val);
|
|
|
+ },
|
|
|
+ [formatLabel, state, allowedValues]
|
|
|
+ );
|
|
|
+
|
|
|
+ const selectedRange =
|
|
|
+ nThumbs > 1
|
|
|
+ ? [Math.min(...state.values), Math.max(...state.values)]
|
|
|
+ : [min, state.values[0]];
|
|
|
+
|
|
|
+ return (
|
|
|
+ <SliderGroup {...groupProps} className={className}>
|
|
|
+ {label && (
|
|
|
+ <SliderLabelWrapper className="label-container">
|
|
|
+ <SliderLabel {...labelProps}>{label}</SliderLabel>
|
|
|
+ <SliderLabelOutput {...outputProps}>
|
|
|
+ {nThumbs > 1
|
|
|
+ ? `${getFormattedValue(selectedRange[0])}–${getFormattedValue(
|
|
|
+ selectedRange[1]
|
|
|
+ )}`
|
|
|
+ : getFormattedValue(selectedRange[1])}
|
|
|
+ </SliderLabelOutput>
|
|
|
+ </SliderLabelWrapper>
|
|
|
+ )}
|
|
|
+
|
|
|
+ <Tooltip
|
|
|
+ title={disabledReason}
|
|
|
+ disabled={!disabled}
|
|
|
+ skipWrapper
|
|
|
+ isHoverable
|
|
|
+ position="bottom"
|
|
|
+ offset={8}
|
|
|
+ >
|
|
|
+ <SliderTrack
|
|
|
+ ref={trackRef}
|
|
|
+ {...trackProps}
|
|
|
+ hasThumbLabels={showThumbLabels && !label}
|
|
|
+ hasTickLabels={showTickLabels && allTickValues.length > 0}
|
|
|
+ >
|
|
|
+ <SliderLowerTrack
|
|
|
+ role="presentation"
|
|
|
+ disabled={disabled}
|
|
|
+ error={error}
|
|
|
+ style={{
|
|
|
+ left: `${state.getValuePercent(selectedRange[0]) * 100}%`,
|
|
|
+ right: `${100 - state.getValuePercent(selectedRange[1]) * 100}%`,
|
|
|
+ }}
|
|
|
+ />
|
|
|
+
|
|
|
+ {allTickValues.map((tickValue, index) => (
|
|
|
+ <SliderTick
|
|
|
+ key={tickValue}
|
|
|
+ aria-hidden
|
|
|
+ error={error}
|
|
|
+ disabled={disabled}
|
|
|
+ inSelection={tickValue >= selectedRange[0] && tickValue <= selectedRange[1]}
|
|
|
+ style={{left: `${(state.getValuePercent(tickValue) * 100).toFixed(2)}%`}}
|
|
|
+ justifyContent={
|
|
|
+ index === 0
|
|
|
+ ? 'start'
|
|
|
+ : index === allTickValues.length - 1
|
|
|
+ ? 'end'
|
|
|
+ : 'center'
|
|
|
+ }
|
|
|
+ >
|
|
|
+ {showTickLabels && (
|
|
|
+ <SliderTickLabel>{getFormattedValue(tickValue)}</SliderTickLabel>
|
|
|
+ )}
|
|
|
+ </SliderTick>
|
|
|
+ ))}
|
|
|
+
|
|
|
+ {[...new Array(nThumbs)].map((_, index) => (
|
|
|
+ <SliderThumb
|
|
|
+ ref={node => {
|
|
|
+ if (!node) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ refs.current = [
|
|
|
+ ...refs.current.slice(0, index),
|
|
|
+ node,
|
|
|
+ ...refs.current.slice(index + 1),
|
|
|
+ ];
|
|
|
+ }}
|
|
|
+ key={index}
|
|
|
+ index={index}
|
|
|
+ state={state}
|
|
|
+ trackRef={trackRef}
|
|
|
+ showLabel={showThumbLabels && !label}
|
|
|
+ getFormattedValue={getFormattedValue}
|
|
|
+ isRequired={required}
|
|
|
+ autoFocus={autoFocus && index === 0}
|
|
|
+ onFocus={onFocus}
|
|
|
+ onBlur={onBlur}
|
|
|
+ onFocusChange={onFocusChange}
|
|
|
+ error={error}
|
|
|
+ />
|
|
|
+ ))}
|
|
|
+ </SliderTrack>
|
|
|
+ </Tooltip>
|
|
|
+ </SliderGroup>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+const Slider = forwardRef(BaseSlider);
|
|
|
+
|
|
|
+export {Slider};
|
|
|
+
|
|
|
+const SliderGroup = styled('div')`
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ justify-content: center;
|
|
|
+ white-space: nowrap;
|
|
|
+`;
|
|
|
+
|
|
|
+const SliderLabelWrapper = styled('div')`
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ margin-bottom: ${space(1.5)};
|
|
|
+`;
|
|
|
+
|
|
|
+const SliderLabel = styled('label')`
|
|
|
+ font-weight: 400;
|
|
|
+ color: ${p => p.theme.textColor};
|
|
|
+`;
|
|
|
+
|
|
|
+const SliderLabelOutput = styled('output')`
|
|
|
+ margin: 0;
|
|
|
+ padding: 0;
|
|
|
+ font-variant-numeric: tabular-nums;
|
|
|
+ color: ${p => p.theme.subText};
|
|
|
+`;
|
|
|
+
|
|
|
+const SliderTrack = styled('div')<{hasThumbLabels: boolean; hasTickLabels: boolean}>`
|
|
|
+ position: relative;
|
|
|
+ width: calc(100% - 2px);
|
|
|
+ height: 3px;
|
|
|
+ border-radius: 3px;
|
|
|
+ background: ${p => p.theme.border};
|
|
|
+ margin-left: 1px; /* to better align track with label */
|
|
|
+
|
|
|
+ margin-bottom: ${p => (p.hasTickLabels ? '2em' : '0.5rem')};
|
|
|
+ margin-top: ${p => (p.hasThumbLabels ? '2em' : '0.5rem')};
|
|
|
+
|
|
|
+ /* Users can click on the track to quickly jump to a value. We should extend the click
|
|
|
+ area to make the action easier. */
|
|
|
+ &::before {
|
|
|
+ content: '';
|
|
|
+ width: 100%;
|
|
|
+ height: 1.5rem;
|
|
|
+ border-radius: 50%;
|
|
|
+ position: absolute;
|
|
|
+ top: 50%;
|
|
|
+ left: 50%;
|
|
|
+ transform: translate(-50%, -50%);
|
|
|
+ }
|
|
|
+`;
|
|
|
+
|
|
|
+const SliderLowerTrack = styled('div')<{disabled: boolean; error: boolean}>`
|
|
|
+ position: absolute;
|
|
|
+ height: inherit;
|
|
|
+ border-radius: inherit;
|
|
|
+ background: ${p => p.theme.active};
|
|
|
+ pointer-events: none;
|
|
|
+
|
|
|
+ ${p => p.error && `background: ${p.theme.error};`}
|
|
|
+ ${p => p.disabled && `background: ${p.theme.subText};`}
|
|
|
+`;
|
|
|
+
|
|
|
+const SliderTick = styled('div')<{
|
|
|
+ disabled: boolean;
|
|
|
+ error: boolean;
|
|
|
+ inSelection: boolean;
|
|
|
+ justifyContent: string;
|
|
|
+}>`
|
|
|
+ display: flex;
|
|
|
+ justify-content: ${p => p.justifyContent};
|
|
|
+ position: absolute;
|
|
|
+ top: 50%;
|
|
|
+ transform: translate(-50%, -50%);
|
|
|
+ width: 2px;
|
|
|
+ height: 6px;
|
|
|
+ border-radius: 2px;
|
|
|
+ background: ${p => p.theme.translucentBorder};
|
|
|
+
|
|
|
+ ${p =>
|
|
|
+ p.inSelection &&
|
|
|
+ `background: ${
|
|
|
+ p.disabled ? p.theme.subText : p.error ? p.theme.error : p.theme.active
|
|
|
+ };`}
|
|
|
+`;
|
|
|
+
|
|
|
+const SliderTickLabel = styled('small')`
|
|
|
+ display: inline-block;
|
|
|
+ position: absolute;
|
|
|
+ top: calc(100% + ${space(1)});
|
|
|
+ margin: 0 -1px;
|
|
|
+
|
|
|
+ color: ${p => p.theme.subText};
|
|
|
+ font-size: ${p => p.theme.fontSizeSmall};
|
|
|
+`;
|