123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407 |
- 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 {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 (
- <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};
- `;
|