Browse Source

feat(slider) Add new slider component (#51547)

**Before ——**
<img width="520" alt="image"
src="https://github.com/getsentry/sentry/assets/44172267/7a9c7c08-10fb-48e5-b781-bedab58354dc">

**After ——**
<img width="619" alt="image"
src="https://github.com/getsentry/sentry/assets/44172267/2690bc34-f381-45dc-85e4-9e522f5da7cd">
<img width="619" alt="image"
src="https://github.com/getsentry/sentry/assets/44172267/40eb38f8-ae8d-4610-bfde-1faad0b79be6">
<img width="619" alt="image"
src="https://github.com/getsentry/sentry/assets/44172267/a7d2057b-076d-4b11-b2e2-b56554d82b47">
<img width="619" alt="image"
src="https://github.com/getsentry/sentry/assets/44172267/c8fc895f-a3d5-4d25-9289-03e06a614ee2">
Vu Luong 1 year ago
parent
commit
9280ae9f20

+ 4 - 0
package.json

@@ -26,6 +26,7 @@
     "@react-aria/button": "^3.7.2",
     "@react-aria/focus": "^3.12.1",
     "@react-aria/gridlist": "^3.4.0",
+    "@react-aria/i18n": "^3.7.2",
     "@react-aria/interactions": "^3.15.1",
     "@react-aria/listbox": "^3.9.1",
     "@react-aria/menu": "^3.9.1",
@@ -33,12 +34,15 @@
     "@react-aria/overlays": "^3.14.1",
     "@react-aria/radio": "^3.6.1",
     "@react-aria/separator": "^3.3.2",
+    "@react-aria/slider": "^3.4.1",
     "@react-aria/tabs": "^3.6.0",
     "@react-aria/utils": "^3.17.0",
+    "@react-aria/visually-hidden": "^3.8.1",
     "@react-stately/collections": "^3.8.0",
     "@react-stately/menu": "^3.5.2",
     "@react-stately/numberfield": "^3.4.2",
     "@react-stately/radio": "^3.8.1",
+    "@react-stately/slider": "^3.3.2",
     "@react-stately/tabs": "^3.4.1",
     "@react-stately/tree": "^3.6.1",
     "@react-types/shared": "^3.18.1",

+ 17 - 22
static/app/components/forms/fields/rangeField.tsx

@@ -1,17 +1,11 @@
-import RangeSlider from 'sentry/components/forms/controls/rangeSlider';
 import FormField from 'sentry/components/forms/formField';
+import {Slider, SliderProps} from 'sentry/components/slider';
 
 // XXX(epurkhiser): This is wrong, it should not be inheriting these props
-import {InputFieldProps, OnEvent} from './inputField';
-
-type DisabledFunction = (props: Omit<RangeFieldProps, 'formatMessageValue'>) => boolean;
-type PlaceholderFunction = (props: any) => React.ReactNode;
+import {InputFieldProps} from './inputField';
 
 export interface RangeFieldProps
-  extends Omit<
-      React.ComponentProps<typeof RangeSlider>,
-      'value' | 'disabled' | 'placeholder' | 'css'
-    >,
+  extends Omit<SliderProps, 'value' | 'defaultValue' | 'disabled' | 'error'>,
     Omit<
       InputFieldProps,
       | 'disabled'
@@ -20,22 +14,14 @@ export interface RangeFieldProps
       | 'onChange'
       | 'max'
       | 'min'
+      | 'onFocus'
       | 'onBlur'
       | 'css'
       | 'formatMessageValue'
     > {
-  disabled?: boolean | DisabledFunction;
+  disabled?: boolean | ((props: Omit<RangeFieldProps, 'formatMessageValue'>) => boolean);
   disabledReason?: React.ReactNode;
   formatMessageValue?: false | Function;
-  placeholder?: string | PlaceholderFunction;
-}
-
-function onChange(
-  fieldOnChange: OnEvent,
-  value: number | '',
-  e: React.FormEvent<HTMLInputElement>
-) {
-  fieldOnChange(value, e);
 }
 
 function defaultFormatMessageValue(value: number | '', {formatLabel}: RangeFieldProps) {
@@ -58,12 +44,21 @@ function RangeField({
 
   return (
     <FormField {...props}>
-      {({children: _children, onChange: fieldOnChange, onBlur, value, ...fieldProps}) => (
-        <RangeSlider
+      {({
+        children: _children,
+        onChange: fieldOnChange,
+        label,
+        onBlur,
+        value,
+        ...fieldProps
+      }) => (
+        <Slider
           {...fieldProps}
+          aria-label={label}
+          showThumbLabels
           value={value}
           onBlur={onBlur}
-          onChange={(val, event) => onChange(fieldOnChange, val, event)}
+          onChange={val => fieldOnChange(val, new MouseEvent(''))}
         />
       )}
     </FormField>

+ 2 - 6
static/app/components/forms/types.tsx

@@ -1,10 +1,10 @@
 import {createFilter} from 'react-select';
 
 import type {AlertProps} from 'sentry/components/alert';
-import RangeSlider from 'sentry/components/forms/controls/rangeSlider';
 import {ChoiceMapperProps} from 'sentry/components/forms/fields/choiceMapperField';
 import {SelectAsyncFieldProps} from 'sentry/components/forms/fields/selectAsyncField';
 import FormModel from 'sentry/components/forms/model';
+import {SliderProps} from 'sentry/components/slider';
 import {AvatarProject, Project, SelectValue} from 'sentry/types';
 
 export const FieldType = [
@@ -136,11 +136,7 @@ type NumberType = {type: 'number'} & {
   step?: number;
 };
 
-type RangeSliderProps = React.ComponentProps<typeof RangeSlider>;
-
-type RangeType = {type: 'range'} & Omit<RangeSliderProps, 'value'> & {
-    value?: Pick<RangeSliderProps, 'value'>;
-  };
+type RangeType = {type: 'range'} & SliderProps;
 
 type FileType = {type: 'file'} & {
   accept?: string[];

+ 148 - 0
static/app/components/slider/index.spec.tsx

@@ -0,0 +1,148 @@
+import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+
+import {Slider} from 'sentry/components/slider';
+
+describe('Slider', function () {
+  it('renders', function () {
+    render(<Slider label="Test" min={0} max={10} step={1} defaultValue={5} />);
+
+    expect(screen.getByRole('group', {name: 'Test'})).toBeInTheDocument();
+    expect(screen.getByRole('status')).toHaveTextContent('5'); // <output /> element
+
+    const slider = screen.getByRole('slider', {name: 'Test'});
+    expect(slider).toBeInTheDocument();
+    expect(slider).toHaveValue('5');
+    expect(slider).toHaveAttribute('min', '0');
+    expect(slider).toHaveAttribute('max', '10');
+  });
+
+  it('renders without label/output', function () {
+    render(<Slider aria-label="Test" min={0} max={10} step={1} defaultValue={5} />);
+    expect(screen.queryByRole('status')).not.toBeInTheDocument();
+  });
+
+  it('calls onChange/onChangeEnd', async function () {
+    const onChangeMock = jest.fn();
+    const onChangeEndMock = jest.fn();
+    render(
+      <Slider
+        label="Test"
+        min={5}
+        max={10}
+        defaultValue={5}
+        onChange={onChangeMock}
+        onChangeEnd={onChangeEndMock}
+      />
+    );
+
+    // To focus on the slider, we should call the focus() method. The slider input element
+    // is visually hidden and only rendered for screen-reader & keyboard accessibility —
+    // users can't actually click on it.
+    screen.getByRole('slider', {name: 'Test'}).focus();
+
+    await userEvent.keyboard('{ArrowRight}');
+    expect(onChangeMock).toHaveBeenCalledWith(6);
+    // onChangeEnd is called after the user stops dragging, but we can't simulate mouse
+    // drags with RTL. Here we're just checking that it's called after a key press.
+    expect(onChangeEndMock).toHaveBeenCalledWith(6);
+  });
+
+  it('works with larger step size', async function () {
+    const onChangeEndMock = jest.fn();
+    render(
+      <Slider
+        label="Test"
+        min={0}
+        max={10}
+        step={5}
+        defaultValue={5}
+        onChangeEnd={onChangeEndMock}
+      />
+    );
+
+    // To focus on the slider, we should call the focus() method. The slider input element
+    // is visually hidden and only rendered for screen-reader & keyboard accessibility —
+    // users can't actually click on it.
+    screen.getByRole('slider', {name: 'Test'}).focus();
+
+    await userEvent.keyboard('{ArrowRight}');
+    expect(onChangeEndMock).toHaveBeenCalledWith(10);
+  });
+
+  it('supports advanced keyboard navigation', async function () {
+    const onChangeEndMock = jest.fn();
+    render(
+      <Slider
+        label="Test"
+        min={5}
+        max={100}
+        defaultValue={5}
+        onChangeEnd={onChangeEndMock}
+      />
+    );
+
+    // To focus on the slider, we should call the focus() method. The slider input element
+    // is visually hidden and only rendered for screen-reader & keyboard accessibility —
+    // users can't actually click on it.
+    screen.getByRole('slider', {name: 'Test'}).focus();
+
+    // Pressing Arrow Right/Left increases/decreases value by 1
+    await userEvent.keyboard('{ArrowRight}');
+    expect(onChangeEndMock).toHaveBeenCalledWith(6);
+    await userEvent.keyboard('{ArrowLeft}');
+    expect(onChangeEndMock).toHaveBeenCalledWith(5);
+
+    // Pressing Arrow Right/Left while holding Shift increases/decreases value by 10
+    await userEvent.keyboard('{Shift>}{ArrowRight}{/Shift}');
+    expect(onChangeEndMock).toHaveBeenCalledWith(15);
+    await userEvent.keyboard('{Shift>}{ArrowLeft}{/Shift}');
+    expect(onChangeEndMock).toHaveBeenCalledWith(5);
+
+    // Pressing Page Up/Down increases/decreases value by 10
+    await userEvent.keyboard('{PageUp}');
+    expect(onChangeEndMock).toHaveBeenCalledWith(6);
+    await userEvent.keyboard('{PageDown}');
+    expect(onChangeEndMock).toHaveBeenCalledWith(5);
+
+    // Pressing Home/End moves value to the min/max position
+    await userEvent.keyboard('{Home}');
+    expect(onChangeEndMock).toHaveBeenCalledWith(5);
+    await userEvent.keyboard('{End}');
+    expect(onChangeEndMock).toHaveBeenCalledWith(100);
+  });
+
+  it('works with two thumbs', async function () {
+    const onChangeEndMock = jest.fn();
+    render(
+      <Slider
+        label="Test"
+        min={5}
+        max={10}
+        defaultValue={[6, 8]}
+        onChangeEnd={onChangeEndMock}
+      />
+    );
+
+    const sliders = screen.getAllByRole('slider', {name: 'Test'});
+
+    // First slider
+    await userEvent.tab();
+    expect(sliders[0]).toHaveFocus();
+    expect(sliders[0]).toHaveValue('6');
+    expect(sliders[0]).toHaveAttribute('min', '5');
+    expect(sliders[0]).toHaveAttribute('max', '8'); // can't go above second slider's value
+
+    await userEvent.keyboard('{ArrowRight}');
+    expect(onChangeEndMock).toHaveBeenCalledWith([7, 8]);
+
+    // Second slider
+    await userEvent.tab();
+    expect(sliders[1]).toHaveFocus();
+    expect(sliders[1]).toHaveValue('8');
+    expect(sliders[1]).toHaveAttribute('min', '7'); // can't go below first slider's value
+    expect(sliders[1]).toHaveAttribute('max', '10');
+
+    await userEvent.keyboard('{ArrowRight}');
+    expect(onChangeEndMock).toHaveBeenCalledWith([7, 9]);
+  });
+});

+ 394 - 0
static/app/components/slider/index.tsx

@@ -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};
+`;

+ 136 - 0
static/app/components/slider/thumb.tsx

@@ -0,0 +1,136 @@
+import {forwardRef, useRef} from 'react';
+import styled from '@emotion/styled';
+import {AriaSliderThumbOptions, useSliderThumb} from '@react-aria/slider';
+import {VisuallyHidden} from '@react-aria/visually-hidden';
+import {SliderState} from '@react-stately/slider';
+
+import {space} from 'sentry/styles/space';
+import mergeRefs from 'sentry/utils/mergeRefs';
+
+export interface SliderThumbProps extends Omit<AriaSliderThumbOptions, 'inputRef'> {
+  getFormattedValue: (value: number) => React.ReactNode;
+  state: SliderState;
+  error?: boolean;
+  showLabel?: boolean;
+}
+
+function BaseSliderThumb(
+  {
+    index,
+    state,
+    trackRef,
+    error = false,
+    getFormattedValue,
+    showLabel,
+    ...props
+  }: SliderThumbProps,
+  forwardedRef: React.ForwardedRef<HTMLInputElement>
+) {
+  const inputRef = useRef<HTMLInputElement>(null);
+  const {thumbProps, inputProps, isDisabled, isFocused} = useSliderThumb(
+    {...props, index, trackRef, inputRef, validationState: error ? 'invalid' : 'valid'},
+    state
+  );
+
+  return (
+    <SliderThumbWrap
+      {...thumbProps}
+      isDisabled={isDisabled}
+      isFocused={isFocused}
+      error={error}
+    >
+      {showLabel && (
+        <SliderThumbLabel
+          aria-hidden
+          style={{
+            // Align thumb label with the track's edges. At 0% (min value) the label's
+            // leading edge should align with the track's leading edge. At 100% (max value)
+            // the label's trailing edge should align with the track's trailing edge.
+            left: `${state.getThumbPercent(index ?? 0) * 100}%`,
+            transform: `translateX(${-state.getThumbPercent(index ?? 0) * 100}%)`,
+          }}
+        >
+          {getFormattedValue(state.values[index ?? 0])}
+        </SliderThumbLabel>
+      )}
+      <VisuallyHidden>
+        <input ref={mergeRefs([inputRef, forwardedRef])} {...inputProps} />
+      </VisuallyHidden>
+    </SliderThumbWrap>
+  );
+}
+
+const SliderThumb = forwardRef(BaseSliderThumb);
+
+export {SliderThumb};
+
+const SliderThumbWrap = styled('div')<{
+  error: boolean;
+  isDisabled: boolean;
+  isFocused: boolean;
+}>`
+  top: 50%;
+  width: 1rem;
+  height: 1rem;
+  border-radius: 50%;
+  background: ${p => p.theme.active};
+  color: ${p => p.theme.activeText};
+  border: solid 2px ${p => p.theme.background};
+  cursor: pointer;
+  transition: box-shadow 0.1s, background 0.1s;
+
+  &:hover {
+    background: ${p => p.theme.activeHover};
+  }
+
+  ${p =>
+    p.error &&
+    `
+    background: ${p.theme.error};
+    color: ${p.theme.errorText};
+
+    &:hover {
+      background: ${p.theme.error};
+    }
+  `}
+
+  ${p =>
+    p.isFocused &&
+    `
+      box-shadow: 0 0 0 2px ${p.error ? p.theme.errorFocus : p.theme.focus};
+      z-index:1;
+    `}
+
+    ${p =>
+    p.isDisabled &&
+    `
+        cursor: initial;
+        background: ${p.theme.subText};
+        color: ${p.theme.subText};
+
+        &:hover {
+          background: ${p.theme.subText};
+        }
+      `};
+
+  /* Extend click area */
+  &::before {
+    content: '' / '';
+    width: calc(100% + 0.5rem);
+    height: calc(100% + 0.5rem);
+    border-radius: 50%;
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%);
+  }
+`;
+
+const SliderThumbLabel = styled('span')`
+  position: absolute;
+  bottom: calc(100% + ${space(0.25)});
+
+  font-size: ${p => p.theme.fontSizeSmall};
+  font-weight: 600;
+  font-variant-numeric: tabular-nums;
+`;

+ 4 - 0
static/app/utils/theme.tsx

@@ -254,24 +254,28 @@ const generateAliases = (colors: BaseColors) => ({
    */
   success: colors.green300,
   successText: colors.green400,
+  successFocus: colors.green200,
 
   /**
    * A color that denotes an error, or something that is wrong
    */
   error: colors.red300,
   errorText: colors.red400,
+  errorFocus: colors.red200,
 
   /**
    * A color that denotes danger, for dangerous actions like deletion
    */
   danger: colors.red300,
   dangerText: colors.red400,
+  dangerFocus: colors.red200,
 
   /**
    * A color that denotes a warning
    */
   warning: colors.yellow300,
   warningText: colors.yellow400,
+  warningFocus: colors.yellow200,
 
   /**
    * A color that indicates something is disabled where user can not interact or use

+ 5 - 8
static/app/views/settings/organizationRateLimits/organizationRateLimits.spec.jsx

@@ -1,4 +1,4 @@
-import {fireEvent, render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
 
 import OrganizationRateLimits from 'sentry/views/settings/organizationRateLimits/organizationRateLimits';
 
@@ -56,10 +56,8 @@ describe('Organization Rate Limits', function () {
     expect(mock).not.toHaveBeenCalled();
 
     // Change Account Limit
-    // Remember value needs to be an index of allowedValues for account limit
-    const slider = screen.getByRole('slider', {name: 'Account Limit'});
-    fireEvent.change(slider, {target: {value: 11}});
-    await userEvent.click(slider);
+    screen.getByRole('slider', {name: 'Account Limit'}).focus();
+    await userEvent.keyboard('{ArrowLeft>5}');
     await userEvent.tab();
 
     expect(mock).toHaveBeenCalledWith(
@@ -85,9 +83,8 @@ describe('Organization Rate Limits', function () {
     expect(mock).not.toHaveBeenCalled();
 
     // Change Project Rate Limit
-    const slider = screen.getByRole('slider', {name: 'Per-Project Limit'});
-    fireEvent.change(slider, {target: {value: 100}});
-    await userEvent.click(slider);
+    screen.getByRole('slider', {name: 'Per-Project Limit'}).focus();
+    await userEvent.keyboard('{ArrowRight>5}');
     await userEvent.tab();
 
     expect(mock).toHaveBeenCalledWith(

+ 18 - 5
static/app/views/settings/projectPerformance/projectPerformance.spec.tsx

@@ -1,5 +1,4 @@
 import {
-  fireEvent,
   render,
   renderGlobalModal,
   screen,
@@ -285,8 +284,17 @@ describe('projectPerformance', function () {
       sliderIndex,
     }) => {
       // Mock endpoints
-      const mockGETBody = {};
-      mockGETBody[threshold] = defaultValue;
+      const mockGETBody = {
+        [threshold]: defaultValue,
+        n_plus_one_db_queries_detection_enabled: true,
+        slow_db_queries_detection_enabled: true,
+        db_on_main_thread_detection_enabled: true,
+        file_io_on_main_thread_detection_enabled: true,
+        consecutive_db_queries_detection_enabled: true,
+        large_render_blocking_asset_detection_enabled: true,
+        uncompressed_assets_detection_enabled: true,
+        large_http_payload_detection_enabled: true,
+      };
       const performanceIssuesGetMock = MockApiClient.addMockResponse({
         url: '/projects/org-slug/project-slug/performance-issues/configure/',
         method: 'GET',
@@ -329,9 +337,14 @@ describe('projectPerformance', function () {
       expect(slider).toHaveValue(indexOfValue.toString());
 
       // Slide value on range slider.
-      fireEvent.change(slider, {target: {value: newValueIndex}});
+      slider.focus();
+      const indexDelta = newValueIndex - indexOfValue;
+      await userEvent.keyboard(
+        indexDelta > 0 ? `{ArrowRight>${indexDelta}}` : `{ArrowLeft>${-indexDelta}}`
+      );
+      await userEvent.tab();
+
       expect(slider).toHaveValue(newValueIndex.toString());
-      fireEvent.keyUp(slider);
 
       // Ensure that PUT request is fired to update
       // project settings

+ 36 - 0
yarn.lock

@@ -2011,6 +2011,23 @@
     "@react-types/shared" "^3.18.1"
     "@swc/helpers" "^0.4.14"
 
+"@react-aria/slider@^3.4.1":
+  version "3.4.1"
+  resolved "https://registry.yarnpkg.com/@react-aria/slider/-/slider-3.4.1.tgz#b56c4b1bb82a4038150c2e67953a55e3337e6231"
+  integrity sha512-gJTfwZGGGv0dPUO3rC8HCyOXnAgMagJZnV3gIILfzNWZHZLYbLze+IbTSNtNKGIvt4pAKTV0njLDLlxFZlAadw==
+  dependencies:
+    "@react-aria/focus" "^3.12.1"
+    "@react-aria/i18n" "^3.7.2"
+    "@react-aria/interactions" "^3.15.1"
+    "@react-aria/label" "^3.5.2"
+    "@react-aria/utils" "^3.17.0"
+    "@react-stately/radio" "^3.8.1"
+    "@react-stately/slider" "^3.3.2"
+    "@react-types/radio" "^3.4.2"
+    "@react-types/shared" "^3.18.1"
+    "@react-types/slider" "^3.5.1"
+    "@swc/helpers" "^0.4.14"
+
 "@react-aria/spinbutton@^3.4.1":
   version "3.4.1"
   resolved "https://registry.yarnpkg.com/@react-aria/spinbutton/-/spinbutton-3.4.1.tgz#627db560317fee187854d48c6e31a4f2f0591a3e"
@@ -2161,6 +2178,18 @@
     "@react-types/shared" "^3.18.1"
     "@swc/helpers" "^0.4.14"
 
+"@react-stately/slider@^3.3.2":
+  version "3.3.2"
+  resolved "https://registry.yarnpkg.com/@react-stately/slider/-/slider-3.3.2.tgz#9e933fe5078ed0272f398c1c11ad078b7945b53d"
+  integrity sha512-UHyBdFR/3Wl1UZmwxWwJ1rb/OCYhY62zZaN7GZrVsnjQ0ng7mFqkb6O0/SXWjsfXnmRAMqCg4ARk82d1PRUfsg==
+  dependencies:
+    "@react-aria/i18n" "^3.7.2"
+    "@react-aria/utils" "^3.17.0"
+    "@react-stately/utils" "^3.6.0"
+    "@react-types/shared" "^3.18.1"
+    "@react-types/slider" "^3.5.1"
+    "@swc/helpers" "^0.4.14"
+
 "@react-stately/tabs@^3.4.1":
   version "3.4.1"
   resolved "https://registry.yarnpkg.com/@react-stately/tabs/-/tabs-3.4.1.tgz#f1d74551808f4d0a33f1c8d0e918bfbb5feeea03"
@@ -2278,6 +2307,13 @@
   resolved "https://registry.yarnpkg.com/@react-types/shared/-/shared-3.18.1.tgz#45bac7a1a433916d16535ea583d86a2b4c72ff8c"
   integrity sha512-OpTYRFS607Ctfd6Tmhyk6t6cbFyDhO5K+etU35X50pMzpypo1b7vF0mkngEeTc0Xwl0e749ONZNPZskMyu5k8w==
 
+"@react-types/slider@^3.5.1":
+  version "3.5.1"
+  resolved "https://registry.yarnpkg.com/@react-types/slider/-/slider-3.5.1.tgz#bae46025de7d02a84918b3aca0e3ffd647e4fdf2"
+  integrity sha512-8+AMNexx7q7DqfAtQKC5tgnZdG/tIwG2tcEbFCfAQA09Djrt/xiMNz+mc7SsV1PWoWwVuSDFH9QqKPodOrJHDg==
+  dependencies:
+    "@react-types/shared" "^3.18.1"
+
 "@react-types/tabs@^3.3.0":
   version "3.3.0"
   resolved "https://registry.yarnpkg.com/@react-types/tabs/-/tabs-3.3.0.tgz#d8230bac82fcd1dca414fbc1c17b769cef9c5bd8"