BUI - Add new range slider component

Signed-off-by: AmbrishRamachandiran <ambrish.r@infosys.com>
This commit is contained in:
AmbrishRamachandiran
2026-03-04 16:21:26 +05:30
parent 11c4e69eb7
commit 8d79835cc0
14 changed files with 833 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/ui': patch
---
Added RangeSlider component for selecting numeric ranges.
Affected components: RangeSlider
@@ -0,0 +1,81 @@
'use client';
import { RangeSlider } from '../../../../../packages/ui/src/components/RangeSlider/RangeSlider';
export const Default = () => {
return (
<RangeSlider
label="Price Range"
minValue={0}
maxValue={1000}
defaultValue={[200, 800]}
showValueLabel
/>
);
};
export const WithCustomRange = () => {
return (
<RangeSlider
label="Temperature (°C)"
minValue={-20}
maxValue={40}
defaultValue={[0, 20]}
step={5}
showValueLabel
/>
);
};
export const WithFormattedValues = () => {
return (
<RangeSlider
label="Budget"
minValue={0}
maxValue={10000}
defaultValue={[2000, 8000]}
step={100}
showValueLabel
formatValue={(value: number) => `$${value.toLocaleString()}`}
/>
);
};
export const WithDescription = () => {
return (
<RangeSlider
label="Age Range"
description="Select the age range for your target audience"
minValue={0}
maxValue={100}
defaultValue={[18, 65]}
showValueLabel
/>
);
};
export const Required = () => {
return (
<RangeSlider
label="Score Range"
minValue={0}
maxValue={100}
defaultValue={[20, 80]}
isRequired
showValueLabel
/>
);
};
export const Disabled = () => {
return (
<RangeSlider
label="Disabled Range"
minValue={0}
maxValue={100}
defaultValue={[30, 70]}
isDisabled
showValueLabel
/>
);
};
@@ -0,0 +1,101 @@
import { PropsTable } from '@/components/PropsTable';
import { Snippet } from '@/components/Snippet';
import { CodeBlock } from '@/components/CodeBlock';
import { ReactAriaLink } from '@/components/ReactAriaLink';
import { rangeSliderPropDefs } from './props-definition';
import {
snippetUsage,
defaultSnippet,
withCustomRangeSnippet,
withFormattedValuesSnippet,
withDescriptionSnippet,
requiredSnippet,
disabledSnippet,
} from './snippets';
import {
Default,
WithCustomRange,
WithFormattedValues,
WithDescription,
Required,
Disabled,
} from './components';
import { PageTitle } from '@/components/PageTitle';
import { Theming } from '@/components/Theming';
import { ChangelogComponent } from '@/components/ChangelogComponent';
import { RangeSliderDefinition } from '../../../utils/definitions';
export const reactAriaUrls = {
slider: 'https://react-spectrum.adobe.com/react-aria/Slider.html',
};
<PageTitle
title="RangeSlider"
description="A dual-thumb slider for selecting a numeric range with customizable formatting and validation."
/>
<Snippet align="center" py={4} preview={<Default />} code={defaultSnippet} />
## Usage
<CodeBlock code={snippetUsage} />
## API reference
<PropsTable data={rangeSliderPropDefs} />
<ReactAriaLink component="Slider" href={reactAriaUrls.slider} />
## Examples
### Custom range
Define custom minimum, maximum, and step values for specific use cases.
<Snippet
align="center"
py={4}
preview={<WithCustomRange />}
code={withCustomRangeSnippet}
/>
### Formatted values
Use the `formatValue` prop to customize how values are displayed.
<Snippet
align="center"
py={4}
preview={<WithFormattedValues />}
code={withFormattedValuesSnippet}
/>
### With description
Add helpful context with a description below the label.
<Snippet
align="center"
py={4}
preview={<WithDescription />}
code={withDescriptionSnippet}
/>
### Required
Mark a field as required to show a "Required" indicator in the label.
<Snippet align="center" py={4} preview={<Required />} code={requiredSnippet} />
### Disabled
<Snippet
align="center"
py={4}
preview={<Disabled />}
code={disabledSnippet}
/>
<Theming definition={RangeSliderDefinition} />
<ChangelogComponent component="range-slider" />
@@ -0,0 +1,98 @@
import { classNamePropDefs, stylePropDefs } from '@/utils/propDefs';
import type { PropDef } from '@/utils/propDefs';
export const rangeSliderPropDefs: Record<string, PropDef> = {
label: {
type: 'string',
description: 'The label text for the range slider.',
},
description: {
type: 'string',
description: 'Additional description text displayed below the label.',
},
secondaryLabel: {
type: 'string',
description:
'Optional secondary label displayed next to the main label (e.g., "Optional").',
},
isRequired: {
type: 'boolean',
description:
'Whether the field is required. Displays "Required" in the label if true.',
},
minValue: {
type: 'number',
description: 'The minimum value of the slider range.',
default: '0',
},
maxValue: {
type: 'number',
description: 'The maximum value of the slider range.',
default: '100',
},
step: {
type: 'number',
description: 'The step increment for slider values.',
default: '1',
},
value: {
type: 'enum',
values: ['number[]'],
description:
'Controlled value as an array [min, max]. Use with onChange for controlled behavior.',
},
defaultValue: {
type: 'enum',
values: ['number[]'],
description: 'Initial value as an array [min, max] for uncontrolled usage.',
default: '[0, 100]',
},
onChange: {
type: 'enum',
values: ['(value: number[]) => void'],
description: 'Called when the slider range changes.',
},
onChangeEnd: {
type: 'enum',
values: ['(value: number[]) => void'],
description:
'Called when the user stops dragging, useful for triggering actions only on final values.',
},
showValueLabel: {
type: 'boolean',
description:
'Whether to display the current range values above the slider.',
default: 'false',
},
formatValue: {
type: 'enum',
values: ['(value: number) => string'],
description:
'Custom formatter function for displaying values (e.g., adding currency symbols).',
},
isDisabled: {
type: 'boolean',
description: 'Prevents user interaction when true.',
},
orientation: {
type: 'enum',
values: ['horizontal', 'vertical'],
description: 'The orientation of the slider.',
default: 'horizontal',
},
name: {
type: 'string',
description: 'Form field name for form submission.',
},
'aria-label': {
type: 'string',
description:
'Accessible label for screen readers when no visible label is provided.',
},
'aria-labelledby': {
type: 'string',
description: 'ID of an element that labels the slider for accessibility.',
},
...classNamePropDefs,
...stylePropDefs,
};
@@ -0,0 +1,62 @@
export const snippetUsage = `import { RangeSlider } from '@backstage/ui';
<RangeSlider
label="My Range"
minValue={0}
maxValue={100}
defaultValue={[25, 75]}
/>`;
export const defaultSnippet = `<RangeSlider
label="Price Range"
minValue={0}
maxValue={1000}
defaultValue={[200, 800]}
showValueLabel
/>`;
export const withCustomRangeSnippet = `<RangeSlider
label="Temperature (°C)"
minValue={-20}
maxValue={40}
defaultValue={[0, 20]}
step={5}
showValueLabel
/>`;
export const withFormattedValuesSnippet = `<RangeSlider
label="Budget"
minValue={0}
maxValue={10000}
defaultValue={[2000, 8000]}
step={100}
showValueLabel
formatValue={(value: number) => \`$\${value.toLocaleString()}\`}
/>`;
export const withDescriptionSnippet = `<RangeSlider
label="Age Range"
description="Select the age range for your target audience"
minValue={0}
maxValue={100}
defaultValue={[18, 65]}
showValueLabel
/>`;
export const requiredSnippet = `<RangeSlider
label="Score Range"
minValue={0}
maxValue={100}
defaultValue={[20, 80]}
isRequired
showValueLabel
/>`;
export const disabledSnippet = `<RangeSlider
label="Disabled Range"
minValue={0}
maxValue={100}
defaultValue={[30, 70]}
isDisabled
showValueLabel
/>`;
+5
View File
@@ -85,6 +85,11 @@ export const components: Page[] = [
title: 'RadioGroup',
slug: 'radio-group',
},
{
title: 'RangeSlider',
slug: 'range-slider',
status: 'new',
},
{
title: 'SearchField',
slug: 'search-field',
@@ -0,0 +1,123 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@layer tokens, base, components, utilities;
@layer components {
.bui-RangeSlider {
display: flex;
flex-direction: column;
gap: var(--bui-space-2);
width: 100%;
color: var(--bui-fg-primary);
&[data-disabled] {
opacity: 0.5;
cursor: not-allowed;
}
&[data-orientation='vertical'] {
height: 200px;
width: auto;
}
}
.bui-RangeSliderHeader {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--bui-space-3);
}
.bui-RangeSliderOutput {
font-size: var(--bui-font-size-2);
font-weight: var(--bui-font-weight-medium);
color: var(--bui-fg-secondary);
white-space: nowrap;
}
.bui-RangeSliderTrack {
position: relative;
height: 4px;
width: 100%;
background: var(--bui-bg-neutral-3);
border-radius: var(--bui-radius-sm);
cursor: pointer;
&[data-disabled] {
cursor: not-allowed;
}
/* Vertical orientation */
[data-orientation='vertical'] & {
width: 4px;
height: 100%;
}
}
.bui-RangeSliderTrackFill {
position: absolute;
top: 0;
height: 100%;
background: var(--bui-bg-solid);
border-radius: var(--bui-radius-sm);
pointer-events: none;
/* Vertical orientation */
[data-orientation='vertical'] & {
width: 100%;
height: auto;
left: 0;
right: 0;
}
}
.bui-RangeSliderThumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--bui-bg-solid);
border: 2px solid var(--bui-bg-solid);
box-shadow:
0 1px 3px 0 rgb(0 0 0 / 0.1),
0 1px 2px -1px rgb(0 0 0 / 0.1);
cursor: grab;
transition: all 200ms;
&[data-focus-visible] {
outline: 2px solid var(--bui-bg-solid);
outline-offset: 2px;
}
&[data-dragging] {
cursor: grabbing;
box-shadow:
0 4px 6px -1px rgb(0 0 0 / 0.1),
0 2px 4px -2px rgb(0 0 0 / 0.1);
}
&[data-disabled] {
cursor: not-allowed;
background: var(--bui-bg-neutral-3);
border-color: var(--bui-bg-neutral-3);
}
/* Hover effect */
&:hover:not([data-disabled]) {
transform: scale(1.1);
}
}
}
@@ -0,0 +1,122 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import preview from '../../../../../.storybook/preview';
import { RangeSlider } from './RangeSlider';
const meta = preview.meta({
title: 'Backstage UI/RangeSlider',
component: RangeSlider,
});
export const Default = meta.story({
args: {
label: 'Price Range',
defaultValue: [25, 75],
showValueLabel: true,
},
});
export const WithCustomRange = meta.story({
args: {
label: 'Temperature (°C)',
minValue: -20,
maxValue: 40,
defaultValue: [0, 20],
step: 5,
showValueLabel: true,
},
});
export const WithFormattedValues = meta.story({
args: {
label: 'Budget',
minValue: 0,
maxValue: 10000,
defaultValue: [2000, 8000],
step: 100,
showValueLabel: true,
formatValue: (value: number) => `$${value.toLocaleString()}`,
},
});
export const WithDescription = meta.story({
args: {
label: 'Age Range',
description: 'Select the age range for your target audience',
minValue: 0,
maxValue: 100,
defaultValue: [18, 65],
showValueLabel: true,
},
});
export const Required = meta.story({
args: {
label: 'Score Range',
defaultValue: [20, 80],
isRequired: true,
showValueLabel: true,
},
});
export const Disabled = meta.story({
args: {
label: 'Disabled Range',
defaultValue: [30, 70],
isDisabled: true,
showValueLabel: true,
},
});
export const WithSteps = meta.story({
args: {
label: 'Rating Range',
minValue: 0,
maxValue: 5,
step: 0.5,
defaultValue: [1.5, 4],
showValueLabel: true,
formatValue: (value: number) => `${value}`,
},
});
export const SmallRange = meta.story({
args: {
label: 'Month Range',
minValue: 1,
maxValue: 12,
defaultValue: [3, 9],
step: 1,
showValueLabel: true,
formatValue: (value: number) => {
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
return months[value - 1] || '';
},
},
});
@@ -0,0 +1,140 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { forwardRef, useEffect } from 'react';
import {
Slider as AriaSlider,
SliderTrack,
SliderThumb,
SliderOutput,
} from 'react-aria-components';
import clsx from 'clsx';
import { FieldLabel } from '../FieldLabel';
import { FieldError } from '../FieldError';
import type { RangeSliderProps } from './types';
import { useStyles } from '../../hooks/useStyles';
import { RangeSliderDefinition } from './definition';
import styles from './RangeSlider.module.css';
/** @public */
export const RangeSlider = forwardRef<HTMLDivElement, RangeSliderProps>(
(props, ref) => {
const {
label,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
} = props;
useEffect(() => {
if (!label && !ariaLabel && !ariaLabelledBy) {
console.warn(
'RangeSlider requires either a visible label, aria-label, or aria-labelledby for accessibility',
);
}
}, [label, ariaLabel, ariaLabelledBy]);
const { classNames, dataAttributes, style, cleanedProps } = useStyles(
RangeSliderDefinition,
{
minValue: 0,
maxValue: 100,
step: 1,
defaultValue: [0, 100],
...props,
},
);
const {
className,
description,
secondaryLabel,
isRequired,
showValueLabel = false,
formatValue = (val: number) => val.toString(),
...rest
} = cleanedProps;
// If a secondary label is provided, use it. Otherwise, use 'Required' if the field is required.
const secondaryLabelText =
secondaryLabel || (isRequired ? 'Required' : null);
return (
<AriaSlider
className={clsx(classNames.root, styles[classNames.root], className)}
{...dataAttributes}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
style={style}
{...rest}
ref={ref}
>
<div className={styles['bui-RangeSliderHeader']}>
<FieldLabel
label={label}
secondaryLabel={secondaryLabelText}
description={description}
/>
{showValueLabel && (
<SliderOutput
className={clsx(classNames.output, styles[classNames.output])}
>
{({ state }) => {
const values = state.values;
if (values.length === 2) {
return `${formatValue(values[0])} - ${formatValue(
values[1],
)}`;
}
return formatValue(values[0]);
}}
</SliderOutput>
)}
</div>
<SliderTrack
className={clsx(classNames.track, styles[classNames.track])}
>
{({ state }) => (
<>
<div
className={clsx(
classNames.trackFill,
styles[classNames.trackFill],
)}
style={{
left: `${state.getThumbPercent(0) * 100}%`,
width: `${
(state.getThumbPercent(1) - state.getThumbPercent(0)) * 100
}%`,
}}
/>
<SliderThumb
index={0}
className={clsx(classNames.thumb, styles[classNames.thumb])}
/>
<SliderThumb
index={1}
className={clsx(classNames.thumb, styles[classNames.thumb])}
/>
</>
)}
</SliderTrack>
<FieldError />
</AriaSlider>
);
},
);
RangeSlider.displayName = 'RangeSlider';
@@ -0,0 +1,34 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ComponentDefinition } from '../../types';
/**
* Component definition for RangeSlider
* @public
*/
export const RangeSliderDefinition = {
classNames: {
root: 'bui-RangeSlider',
track: 'bui-RangeSliderTrack',
trackFill: 'bui-RangeSliderTrackFill',
thumb: 'bui-RangeSliderThumb',
output: 'bui-RangeSliderOutput',
},
dataAttributes: {
disabled: [true, false] as const,
},
} as const satisfies ComponentDefinition;
@@ -0,0 +1,19 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './RangeSlider';
export * from './types';
export { RangeSliderDefinition } from './definition';
@@ -0,0 +1,39 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { SliderProps as AriaSliderProps } from 'react-aria-components';
import type { FieldLabelProps } from '../FieldLabel/types';
/** @public */
export interface RangeSliderProps
extends Omit<AriaSliderProps<number[]>, 'children'>,
Omit<FieldLabelProps, 'htmlFor' | 'id' | 'className'> {
/**
* Whether to show value labels above the thumbs
* @defaultValue false
*/
showValueLabel?: boolean;
/**
* Format the value for display
*/
formatValue?: (value: number) => string;
/**
* Whether the slider is required (displays "Required" in the label)
*/
isRequired?: boolean;
}
+1
View File
@@ -49,6 +49,7 @@ export { MenuDefinition } from './components/Menu/definition';
export { PasswordFieldDefinition } from './components/PasswordField/definition';
export { PopoverDefinition } from './components/Popover/definition';
export { RadioGroupDefinition } from './components/RadioGroup/definition';
export { RangeSliderDefinition } from './components/RangeSlider/definition';
export { SearchFieldDefinition } from './components/SearchField/definition';
export { SelectDefinition } from './components/Select/definition';
export { SkeletonDefinition } from './components/Skeleton/definition';
+1
View File
@@ -41,6 +41,7 @@ export * from './components/ButtonIcon';
export * from './components/ButtonLink';
export * from './components/Checkbox';
export * from './components/RadioGroup';
export * from './components/RangeSlider';
export * from './components/Table';
export * from './components/TablePagination';
export * from './components/Tabs';