Merge pull request #33112 from AmbrishRamachandiran/range-slider-component
BUI - Add new range slider component
This commit is contained in:
@@ -42,6 +42,7 @@ import { RowProps as RowProps_2 } from 'react-aria-components';
|
||||
import type { SearchFieldProps as SearchFieldProps_2 } from 'react-aria-components';
|
||||
import type { SelectProps as SelectProps_2 } from 'react-aria-components';
|
||||
import type { SeparatorProps } from 'react-aria-components';
|
||||
import type { SliderProps as SliderProps_2 } from 'react-aria-components';
|
||||
import type { SortDescriptor as SortDescriptor_2 } from 'react-stately';
|
||||
import type { SubmenuTriggerProps as SubmenuTriggerProps_2 } from 'react-aria-components';
|
||||
import type { SwitchProps as SwitchProps_2 } from 'react-aria-components';
|
||||
@@ -1154,6 +1155,7 @@ export const FieldLabelDefinition: {
|
||||
readonly description: {};
|
||||
readonly htmlFor: {};
|
||||
readonly id: {};
|
||||
readonly descriptionId: {};
|
||||
readonly className: {};
|
||||
};
|
||||
};
|
||||
@@ -1165,6 +1167,7 @@ export type FieldLabelOwnProps = {
|
||||
description?: string | null;
|
||||
htmlFor?: string;
|
||||
id?: string;
|
||||
descriptionId?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
@@ -2459,6 +2462,56 @@ export interface SkeletonProps
|
||||
extends Omit<ComponentProps<'div'>, 'children' | 'className' | 'style'>,
|
||||
SkeletonOwnProps {}
|
||||
|
||||
// @public (undocumented)
|
||||
export const Slider: (<T extends number | number[]>(
|
||||
props: SliderProps<T> & {
|
||||
ref?: React.ForwardedRef<HTMLDivElement>;
|
||||
},
|
||||
) => JSX.Element) & {
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
// @public
|
||||
export const SliderDefinition: {
|
||||
readonly styles: {
|
||||
readonly [key: string]: string;
|
||||
};
|
||||
readonly classNames: {
|
||||
readonly root: 'bui-Slider';
|
||||
readonly header: 'bui-SliderHeader';
|
||||
readonly track: 'bui-SliderTrack';
|
||||
readonly trackFill: 'bui-SliderTrackFill';
|
||||
readonly thumb: 'bui-SliderThumb';
|
||||
readonly output: 'bui-SliderOutput';
|
||||
};
|
||||
readonly propDefs: {
|
||||
readonly className: {};
|
||||
readonly label: {};
|
||||
readonly secondaryLabel: {};
|
||||
readonly description: {};
|
||||
readonly isRequired: {};
|
||||
};
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export interface SliderOwnProps {
|
||||
// (undocumented)
|
||||
className?: string;
|
||||
// (undocumented)
|
||||
description?: FieldLabelProps['description'];
|
||||
// (undocumented)
|
||||
isRequired?: boolean;
|
||||
// (undocumented)
|
||||
label?: FieldLabelProps['label'];
|
||||
// (undocumented)
|
||||
secondaryLabel?: FieldLabelProps['secondaryLabel'];
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export interface SliderProps<T extends number | number[]>
|
||||
extends Omit<SliderProps_2<T>, 'children' | 'className'>,
|
||||
SliderOwnProps {}
|
||||
|
||||
// @public (undocumented)
|
||||
export type SortDescriptor = SortDescriptor_2;
|
||||
|
||||
|
||||
@@ -23,8 +23,15 @@ import { FieldLabelDefinition } from './definition';
|
||||
export const FieldLabel = forwardRef<HTMLDivElement, FieldLabelProps>(
|
||||
(props: FieldLabelProps, ref) => {
|
||||
const { ownProps, restProps } = useDefinition(FieldLabelDefinition, props);
|
||||
const { classes, label, secondaryLabel, description, htmlFor, id } =
|
||||
ownProps;
|
||||
const {
|
||||
classes,
|
||||
label,
|
||||
secondaryLabel,
|
||||
description,
|
||||
htmlFor,
|
||||
id,
|
||||
descriptionId,
|
||||
} = ownProps;
|
||||
|
||||
if (!label) return null;
|
||||
|
||||
@@ -41,7 +48,9 @@ export const FieldLabel = forwardRef<HTMLDivElement, FieldLabelProps>(
|
||||
</Label>
|
||||
)}
|
||||
{description && (
|
||||
<div className={classes.description}>{description}</div>
|
||||
<div className={classes.description} id={descriptionId}>
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -36,6 +36,7 @@ export const FieldLabelDefinition = defineComponent<FieldLabelOwnProps>()({
|
||||
description: {},
|
||||
htmlFor: {},
|
||||
id: {},
|
||||
descriptionId: {},
|
||||
className: {},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -41,6 +41,11 @@ export type FieldLabelOwnProps = {
|
||||
*/
|
||||
id?: string;
|
||||
|
||||
/**
|
||||
* The id to apply to the description element for aria-describedby
|
||||
*/
|
||||
descriptionId?: string;
|
||||
|
||||
className?: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* 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-Slider {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--bui-space-2);
|
||||
width: 100%;
|
||||
color: var(--bui-fg-primary);
|
||||
|
||||
&[data-orientation='vertical'] {
|
||||
height: 200px;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.bui-SliderHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--bui-space-3);
|
||||
}
|
||||
|
||||
.bui-SliderOutput {
|
||||
font-size: var(--bui-font-size-2);
|
||||
font-weight: var(--bui-font-weight-medium);
|
||||
color: var(--bui-fg-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bui-SliderTrack {
|
||||
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-SliderTrackFill {
|
||||
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;
|
||||
top: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.bui-SliderThumb {
|
||||
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: transform 200ms;
|
||||
|
||||
/* Fix: Ensure thumb is vertically centered on track */
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
|
||||
&[data-focus-visible] {
|
||||
outline: 2px solid var(--bui-ring);
|
||||
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);
|
||||
transform: translateY(-50%) scale(1.1);
|
||||
}
|
||||
|
||||
/* Hover effect */
|
||||
&:hover:not([data-disabled]) {
|
||||
transform: translateY(-50%) scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Improved disabled state */
|
||||
.bui-Slider[data-disabled] {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
|
||||
& .bui-SliderTrack {
|
||||
background: var(--bui-bg-neutral-2);
|
||||
}
|
||||
|
||||
& .bui-SliderTrackFill {
|
||||
background: var(--bui-bg-neutral-4);
|
||||
}
|
||||
|
||||
& .bui-SliderThumb {
|
||||
cursor: not-allowed;
|
||||
background: var(--bui-bg-neutral-4);
|
||||
border-color: var(--bui-border-neutral);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 { Slider } from './Slider';
|
||||
|
||||
const meta = preview.meta({
|
||||
title: 'Backstage UI/Slider',
|
||||
component: Slider,
|
||||
});
|
||||
|
||||
export const SingleThumb = meta.story({
|
||||
args: {
|
||||
label: 'Volume',
|
||||
defaultValue: 50,
|
||||
},
|
||||
});
|
||||
|
||||
export const SingleThumbWithRange = meta.story({
|
||||
args: {
|
||||
label: 'Brightness',
|
||||
minValue: 0,
|
||||
maxValue: 100,
|
||||
defaultValue: 75,
|
||||
step: 5,
|
||||
},
|
||||
});
|
||||
|
||||
export const RangeSlider = meta.story({
|
||||
args: {
|
||||
label: 'Price Range',
|
||||
defaultValue: [25, 75],
|
||||
},
|
||||
});
|
||||
|
||||
export const WithCustomRange = meta.story({
|
||||
args: {
|
||||
label: 'Temperature (°C)',
|
||||
minValue: -20,
|
||||
maxValue: 40,
|
||||
defaultValue: [0, 20],
|
||||
step: 5,
|
||||
},
|
||||
});
|
||||
|
||||
export const WithFormattedValues = meta.story({
|
||||
args: {
|
||||
label: 'Budget',
|
||||
minValue: 0,
|
||||
maxValue: 10000,
|
||||
defaultValue: [2000, 8000],
|
||||
step: 100,
|
||||
formatOptions: {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
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],
|
||||
},
|
||||
});
|
||||
|
||||
export const Required = meta.story({
|
||||
args: {
|
||||
label: 'Score Range',
|
||||
defaultValue: [20, 80],
|
||||
isRequired: true,
|
||||
},
|
||||
});
|
||||
|
||||
export const Disabled = meta.story({
|
||||
args: {
|
||||
label: 'Disabled Range',
|
||||
defaultValue: [30, 70],
|
||||
isDisabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
export const WithSteps = meta.story({
|
||||
args: {
|
||||
label: 'Rating',
|
||||
minValue: 0,
|
||||
maxValue: 5,
|
||||
step: 0.5,
|
||||
defaultValue: 3.5,
|
||||
},
|
||||
});
|
||||
|
||||
export const Percentage = meta.story({
|
||||
args: {
|
||||
label: 'Completion',
|
||||
minValue: 0,
|
||||
maxValue: 1,
|
||||
step: 0.01,
|
||||
defaultValue: 0.65,
|
||||
formatOptions: {
|
||||
style: 'percent',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
import { forwardRef, useEffect, useId } 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 { SliderProps } from './types';
|
||||
import { useDefinition } from '../../hooks/useDefinition';
|
||||
import { SliderDefinition } from './definition';
|
||||
|
||||
function SliderImpl<T extends number | number[]>(
|
||||
props: SliderProps<T>,
|
||||
ref: React.ForwardedRef<HTMLDivElement>,
|
||||
) {
|
||||
const { ownProps, restProps } = useDefinition(SliderDefinition, props);
|
||||
const { classes, className, label, secondaryLabel, description, isRequired } =
|
||||
ownProps;
|
||||
|
||||
const labelId = useId();
|
||||
const descriptionId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
if (!label && !restProps['aria-label'] && !restProps['aria-labelledby']) {
|
||||
console.warn(
|
||||
'Slider requires either a visible label, aria-label, or aria-labelledby for accessibility',
|
||||
);
|
||||
}
|
||||
}, [label, restProps]);
|
||||
|
||||
const secondaryLabelText = secondaryLabel || (isRequired ? 'Required' : null);
|
||||
|
||||
return (
|
||||
<AriaSlider
|
||||
className={clsx(classes.root, className)}
|
||||
aria-labelledby={label ? labelId : undefined}
|
||||
aria-describedby={label && description ? descriptionId : undefined}
|
||||
{...restProps}
|
||||
ref={ref}
|
||||
>
|
||||
{label && (
|
||||
<div className={classes.header}>
|
||||
<FieldLabel
|
||||
id={labelId}
|
||||
label={label}
|
||||
secondaryLabel={secondaryLabelText}
|
||||
description={description}
|
||||
descriptionId={description ? descriptionId : undefined}
|
||||
/>
|
||||
<SliderOutput className={classes.output}>
|
||||
{({ state }) =>
|
||||
state.values
|
||||
.map((_, i) => state.getThumbValueLabel(i))
|
||||
.join(' – ')
|
||||
}
|
||||
</SliderOutput>
|
||||
</div>
|
||||
)}
|
||||
<SliderTrack className={classes.track}>
|
||||
{({ state }) => {
|
||||
const numThumbs = state.values.length;
|
||||
|
||||
// Calculate track fill
|
||||
let trackFillStyle: React.CSSProperties;
|
||||
if (numThumbs === 1) {
|
||||
// Single thumb: fill from start to thumb
|
||||
const percent = state.getThumbPercent(0);
|
||||
const isVertical = state.orientation === 'vertical';
|
||||
trackFillStyle = isVertical
|
||||
? { bottom: 0, height: `${percent * 100}%` }
|
||||
: { left: 0, width: `${percent * 100}%` };
|
||||
} else {
|
||||
// Range: fill between thumbs
|
||||
const start = state.getThumbPercent(0);
|
||||
const end = state.getThumbPercent(1);
|
||||
const rangePercent = (end - start) * 100;
|
||||
const isVertical = state.orientation === 'vertical';
|
||||
trackFillStyle = isVertical
|
||||
? { bottom: `${start * 100}%`, height: `${rangePercent}%` }
|
||||
: { left: `${start * 100}%`, width: `${rangePercent}%` };
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classes.trackFill} style={trackFillStyle} />
|
||||
<SliderThumb index={0} className={classes.thumb} />
|
||||
{numThumbs > 1 && (
|
||||
<SliderThumb index={1} className={classes.thumb} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</SliderTrack>
|
||||
<FieldError />
|
||||
</AriaSlider>
|
||||
);
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export const Slider = forwardRef(SliderImpl) as (<T extends number | number[]>(
|
||||
props: SliderProps<T> & { ref?: React.ForwardedRef<HTMLDivElement> },
|
||||
) => JSX.Element) & { displayName: string };
|
||||
|
||||
Slider.displayName = 'Slider';
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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 { defineComponent } from '../../hooks/useDefinition';
|
||||
import type { SliderOwnProps } from './types';
|
||||
import styles from './Slider.module.css';
|
||||
|
||||
/**
|
||||
* Component definition for Slider
|
||||
* @public
|
||||
*/
|
||||
export const SliderDefinition = defineComponent<SliderOwnProps>()({
|
||||
styles,
|
||||
classNames: {
|
||||
root: 'bui-Slider',
|
||||
header: 'bui-SliderHeader',
|
||||
track: 'bui-SliderTrack',
|
||||
trackFill: 'bui-SliderTrackFill',
|
||||
thumb: 'bui-SliderThumb',
|
||||
output: 'bui-SliderOutput',
|
||||
},
|
||||
propDefs: {
|
||||
className: {},
|
||||
label: {},
|
||||
secondaryLabel: {},
|
||||
description: {},
|
||||
isRequired: {},
|
||||
},
|
||||
});
|
||||
@@ -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 { Slider } from './Slider';
|
||||
export * from './types';
|
||||
export { SliderDefinition } from './definition';
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* 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 SliderOwnProps {
|
||||
className?: string;
|
||||
label?: FieldLabelProps['label'];
|
||||
secondaryLabel?: FieldLabelProps['secondaryLabel'];
|
||||
description?: FieldLabelProps['description'];
|
||||
isRequired?: boolean;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface SliderProps<T extends number | number[]>
|
||||
extends Omit<AriaSliderProps<T>, 'children' | 'className'>,
|
||||
SliderOwnProps {}
|
||||
@@ -63,6 +63,7 @@ export {
|
||||
export { SearchFieldDefinition } from './components/SearchField/definition';
|
||||
export { SelectDefinition } from './components/Select/definition';
|
||||
export { SkeletonDefinition } from './components/Skeleton/definition';
|
||||
export { SliderDefinition } from './components/Slider/definition';
|
||||
export { SwitchDefinition } from './components/Switch/definition';
|
||||
export { ToggleButtonDefinition } from './components/ToggleButton/definition';
|
||||
export { ToggleButtonGroupDefinition } from './components/ToggleButtonGroup/definition';
|
||||
|
||||
@@ -41,6 +41,7 @@ export * from './components/ButtonIcon';
|
||||
export * from './components/ButtonLink';
|
||||
export * from './components/Checkbox';
|
||||
export * from './components/RadioGroup';
|
||||
export * from './components/Slider';
|
||||
export * from './components/Table';
|
||||
export * from './components/TablePagination';
|
||||
export * from './components/Tabs';
|
||||
|
||||
Reference in New Issue
Block a user