Files
2024-02-15 17:01:50 +00:00

409 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {forwardRef, useCallback, useImperativeHandle, useMemo, useRef} from 'react';
import isPropValid from '@emotion/is-prop-valid';
import styled from '@emotion/styled';
import {useNumberFormatter} from '@react-aria/i18n';
import type {AriaSliderProps, AriaSliderThumbOptions} from '@react-aria/slider';
import {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 (
<Tooltip
title={disabledReason}
disabled={!disabled}
skipWrapper
isHoverable
position="bottom"
offset={-15}
>
<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>
)}
<SliderTrack
ref={trackRef}
{...trackProps}
disabled={disabled}
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}
isDisabled={disabled}
showLabel={showThumbLabels && !label}
getFormattedValue={getFormattedValue}
isRequired={required}
autoFocus={autoFocus && index === 0}
onFocus={onFocus}
onBlur={onBlur}
onFocusChange={onFocusChange}
error={error}
/>
))}
</SliderTrack>
</SliderGroup>
</Tooltip>
);
}
const Slider = forwardRef(BaseSlider);
export {Slider};
const SliderGroup = styled('div')`
width: 100%;
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', {
shouldForwardProp: prop =>
prop !== 'disabled' && typeof prop === 'string' && isPropValid(prop),
})<{
disabled: boolean;
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')};
${p => p.disabled && `pointer-events: none;`}
/* 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};
`;