Adds a range selector component (#38191)

This commit is contained in:
Echo
2026-03-13 15:28:39 +01:00
committed by GitHub
parent 91407ecc15
commit d26269d68b
3 changed files with 246 additions and 0 deletions

View File

@@ -0,0 +1,128 @@
/*
Inspired by:
https://danielstern.ca/range.css
https://css-tricks.com/styling-cross-browser-compatible-range-inputs-css/
*/
.input {
--color-bg-thumb: var(--color-bg-brand-base);
--color-bg-thumb-hover: var(--color-bg-brand-base-hover);
--color-bg-track: var(--color-bg-secondary);
width: 100%;
margin: 6px 0;
background-color: transparent;
appearance: none;
&:focus {
outline: none;
}
// Thumb
&::-webkit-slider-thumb {
margin-top: -6px;
width: 16px;
height: 16px;
background: var(--color-bg-thumb);
border: 0;
border-radius: 50px;
cursor: pointer;
-webkit-appearance: none;
}
&::-moz-range-thumb {
width: 16px;
height: 16px;
background: var(--color-bg-thumb);
border: 0;
border-radius: 50px;
cursor: pointer;
}
&::-ms-thumb {
width: 16px;
height: 16px;
background: var(--color-bg-thumb);
border: 0;
border-radius: 50px;
cursor: pointer;
margin-top: 0; // Needed to keep the Edge thumb centred
}
&:focus,
&:hover {
&::-webkit-slider-thumb {
background: var(--color-bg-thumb-hover);
}
&::-moz-range-thumb {
background: var(--color-bg-thumb-hover);
}
&::-ms-thumb {
background: var(--color-bg-thumb-hover);
}
}
&:focus-visible {
&::-webkit-slider-thumb {
outline: var(--outline-focus-default);
outline-offset: 2px;
}
&::-moz-range-thumb {
outline: var(--outline-focus-default);
outline-offset: 2px;
}
&::-ms-thumb {
outline: var(--outline-focus-default);
outline-offset: 2px;
}
}
// Track
&::-webkit-slider-runnable-track {
background: var(--color-bg-track);
border: 0;
border-radius: 1.3px;
width: 100%;
height: 4px;
cursor: pointer;
}
&::-moz-range-track {
background: var(--color-bg-track);
border: 0;
border-radius: 1.3px;
width: 100%;
height: 4px;
cursor: pointer;
}
&::-ms-track {
background: var(--color-bg-track);
border: 0;
color: transparent;
width: 100%;
height: 4px;
cursor: pointer;
}
}
.markers {
display: flex;
flex-direction: column;
justify-content: space-between;
writing-mode: vertical-lr;
width: 100%;
font-size: 11px;
color: var(--color-text-secondary);
user-select: none;
option {
padding: 0;
}
}

View File

@@ -0,0 +1,32 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { RangeInputField } from './range_input_field';
const meta = {
title: 'Components/Form Fields/RangeInputField',
component: RangeInputField,
args: {
label: 'Label',
hint: 'This is a description of this form field',
checked: false,
disabled: false,
},
} satisfies Meta<typeof RangeInputField>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Simple: Story = {};
export const Markers: Story = {
args: {
markers: [
{ value: 0, label: 'None' },
{ value: 25, label: 'Some' },
{ value: 50, label: 'Half' },
{ value: 75, label: 'Most' },
{ value: 100, label: 'All' },
],
},
};

View File

@@ -0,0 +1,86 @@
import type { ComponentPropsWithoutRef } from 'react';
import { forwardRef, useId } from 'react';
import classNames from 'classnames';
import { FormFieldWrapper } from './form_field_wrapper';
import type { CommonFieldWrapperProps } from './form_field_wrapper';
import classes from './range_input.module.scss';
export type RangeInputProps = Omit<
ComponentPropsWithoutRef<'input'>,
'type' | 'list'
> & {
markers?: { value: number; label: string }[] | number[];
};
interface Props extends RangeInputProps, CommonFieldWrapperProps {}
/**
* A simple form field for single-line text.
*
* Accepts an optional `hint` and can be marked as required
* or optional (by explicitly setting `required={false}`)
*/
export const RangeInputField = forwardRef<HTMLInputElement, Props>(
(
{ id, label, hint, status, required, wrapperClassName, ...otherProps },
ref,
) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
status={status}
inputId={id}
className={wrapperClassName}
>
{(inputProps) => <RangeInput {...otherProps} {...inputProps} ref={ref} />}
</FormFieldWrapper>
),
);
RangeInputField.displayName = 'RangeInputField';
export const RangeInput = forwardRef<HTMLInputElement, RangeInputProps>(
({ className, markers, id, ...otherProps }, ref) => {
const markersId = useId();
if (!markers) {
return (
<input
{...otherProps}
type='range'
className={classNames(className, classes.input)}
ref={ref}
/>
);
}
return (
<>
<input
{...otherProps}
type='range'
className={classNames(className, classes.input)}
ref={ref}
list={markersId}
/>
<datalist id={markersId} className={classes.markers}>
{markers.map((marker) => {
const value = typeof marker === 'number' ? marker : marker.value;
return (
<option
key={value}
value={value}
label={typeof marker !== 'number' ? marker.label : undefined}
/>
);
})}
</datalist>
</>
);
},
);
RangeInput.displayName = 'RangeInput';