BUI - Add new range slider component
Signed-off-by: AmbrishRamachandiran <ambrish.r@infosys.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user