Fix review comments added single slider

Signed-off-by: AmbrishRamachandiran <ambrish.r@infosys.com>
This commit is contained in:
AmbrishRamachandiran
2026-03-10 10:51:16 +05:30
parent bc19197570
commit 3ae5a679b2
15 changed files with 367 additions and 404 deletions
+48 -47
View File
@@ -39,7 +39,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 } 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';
@@ -1968,52 +1968,6 @@ export interface RadioProps
extends RadioOwnProps,
Omit<RadioProps_2, keyof RadioOwnProps> {}
// @public (undocumented)
export const RangeSlider: ForwardRefExoticComponent<
RangeSliderProps & RefAttributes<HTMLDivElement>
>;
// @public
export const RangeSliderDefinition: {
readonly styles: {
readonly [key: string]: string;
};
readonly classNames: {
readonly root: 'bui-RangeSlider';
readonly header: 'bui-RangeSliderHeader';
readonly track: 'bui-RangeSliderTrack';
readonly trackFill: 'bui-RangeSliderTrackFill';
readonly thumb: 'bui-RangeSliderThumb';
readonly output: 'bui-RangeSliderOutput';
};
readonly propDefs: {
readonly className: {};
};
};
// @public (undocumented)
export interface RangeSliderOwnProps {
// (undocumented)
className?: string;
}
// @public (undocumented)
export interface RangeSliderProps
extends Omit<SliderProps<[number, number]>, 'children'>,
Omit<
FieldLabelProps,
| 'htmlFor'
| 'id'
| 'className'
| 'defaultValue'
| 'onChange'
| 'slot'
| 'style'
> {
formatValue?: (value: number) => string;
showValueLabel?: boolean;
}
// @public (undocumented)
export type Responsive<T> = T | Partial<Record<Breakpoint, T>>;
@@ -2204,6 +2158,53 @@ export interface SkeletonProps
extends Omit<ComponentProps<'div'>, 'children' | 'className' | 'style'>,
SkeletonOwnProps {}
// Warning: (ae-forgotten-export) The symbol "SliderImpl" needs to be exported by the entry point index.d.ts
//
// @public (undocumented)
export const Slider: <T extends number | number[]>(
props: SliderProps<T> & {
ref?: React.ForwardedRef<HTMLDivElement>;
},
) => ReturnType<typeof SliderImpl>;
// @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: {};
};
};
// @public (undocumented)
export interface SliderOwnProps {
// (undocumented)
className?: string;
}
// @public (undocumented)
export interface SliderProps<T extends number | number[]>
extends Omit<SliderProps_2<T>, 'children'>,
Omit<
FieldLabelProps,
| 'htmlFor'
| 'id'
| 'className'
| 'defaultValue'
| 'onChange'
| 'slot'
| 'style'
> {}
// @public (undocumented)
export type SortDescriptor = SortDescriptor_2;
@@ -1,210 +0,0 @@
/*
* 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 { useDefinition } from '../../hooks/useDefinition';
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;
// Extract isRequired from props (inherited from AriaSliderProps)
const isRequired = (props as any).isRequired;
useEffect(() => {
if (!label && !ariaLabel && !ariaLabelledBy) {
console.warn(
'RangeSlider requires either a visible label, aria-label, or aria-labelledby for accessibility',
);
}
}, [label, ariaLabel, ariaLabelledBy]);
const minValue = props.minValue ?? 0;
const maxValue = props.maxValue ?? 100;
// Validate and normalize defaultValue to ensure it's always a 2-tuple
const rawDefaultValue = props.defaultValue;
const normalizedDefaultValue: [number, number] =
Array.isArray(rawDefaultValue) &&
rawDefaultValue.length === 2 &&
typeof rawDefaultValue[0] === 'number' &&
typeof rawDefaultValue[1] === 'number'
? [rawDefaultValue[0], rawDefaultValue[1]]
: [minValue, maxValue];
// Validate and normalize controlled value to ensure it's always a 2-tuple
const rawValue = props.value;
const normalizedValue: [number, number] | undefined =
rawValue === undefined
? undefined
: Array.isArray(rawValue) &&
rawValue.length === 2 &&
typeof rawValue[0] === 'number' &&
typeof rawValue[1] === 'number'
? [rawValue[0], rawValue[1]]
: [minValue, maxValue];
useEffect(() => {
if (
rawDefaultValue !== undefined &&
(!Array.isArray(rawDefaultValue) ||
rawDefaultValue.length !== 2 ||
typeof rawDefaultValue[0] !== 'number' ||
typeof rawDefaultValue[1] !== 'number')
) {
console.warn(
`RangeSlider requires exactly 2 numeric values [min, max], but received invalid defaultValue. Falling back to [${minValue}, ${maxValue}].`,
);
}
if (
rawValue !== undefined &&
(!Array.isArray(rawValue) ||
rawValue.length !== 2 ||
typeof rawValue[0] !== 'number' ||
typeof rawValue[1] !== 'number')
) {
console.warn(
`RangeSlider requires exactly 2 numeric values [min, max], but received invalid value. Falling back to [${minValue}, ${maxValue}].`,
);
}
}, [rawValue, rawDefaultValue, minValue, maxValue]);
const uncontrolledDefaultValue =
normalizedValue === undefined ? normalizedDefaultValue : undefined;
const {
defaultValue: _ignoredDefault,
value: _ignoredValue,
...propsWithoutDefault
} = props;
const { ownProps, restProps, dataAttributes } = useDefinition(
RangeSliderDefinition,
{
minValue,
maxValue,
step: 1,
...(uncontrolledDefaultValue !== undefined
? { defaultValue: uncontrolledDefaultValue }
: {}),
...(normalizedValue !== undefined ? { value: normalizedValue } : {}),
...propsWithoutDefault,
},
);
const { classes, className } = ownProps;
const {
label: _ignoredLabel,
description,
secondaryLabel,
showValueLabel = false,
formatValue = (val: number) => val.toString(),
...rest
} = restProps;
// 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(classes.root, styles[classes.root], className)}
{...dataAttributes}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
{...rest}
ref={ref}
>
{(label || showValueLabel) && (
<div className={clsx(classes.header, styles[classes.header])}>
<FieldLabel
label={label}
secondaryLabel={secondaryLabelText}
description={description}
/>
{showValueLabel && (
<SliderOutput
className={clsx(classes.output, styles[classes.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(classes.track, styles[classes.track])}>
{({ state }) => {
const start = state.getThumbPercent(0);
const end = state.getThumbPercent(1);
const rangePercent = (end - start) * 100;
const isVertical = state.orientation === 'vertical';
const trackFillStyle = isVertical
? {
bottom: `${start * 100}%`,
height: `${rangePercent}%`,
}
: {
left: `${start * 100}%`,
width: `${rangePercent}%`,
};
return (
<>
<div
className={clsx(classes.trackFill, styles[classes.trackFill])}
style={trackFillStyle}
/>
<SliderThumb
index={0}
className={clsx(classes.thumb, styles[classes.thumb])}
/>
<SliderThumb
index={1}
className={clsx(classes.thumb, styles[classes.thumb])}
/>
</>
);
}}
</SliderTrack>
<FieldError />
</AriaSlider>
);
},
);
RangeSlider.displayName = 'RangeSlider';
@@ -17,39 +17,34 @@
@layer tokens, base, components, utilities;
@layer components {
.bui-RangeSlider {
.bui-Slider {
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 {
.bui-SliderHeader {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--bui-space-3);
}
.bui-RangeSliderOutput {
.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-RangeSliderTrack {
.bui-SliderTrack {
position: relative;
height: 4px;
width: 100%;
@@ -68,7 +63,7 @@
}
}
.bui-RangeSliderTrackFill {
.bui-SliderTrackFill {
position: absolute;
top: 0;
height: 100%;
@@ -86,7 +81,7 @@
}
}
.bui-RangeSliderThumb {
.bui-SliderThumb {
width: 20px;
height: 20px;
border-radius: 50%;
@@ -94,7 +89,11 @@
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;
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);
@@ -105,17 +104,32 @@
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);
transform: translateY(-50%) scale(1.1);
}
/* Hover effect */
&:hover:not([data-disabled]) {
transform: scale(1.1);
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);
}
}
}
@@ -14,18 +14,34 @@
* limitations under the License.
*/
import preview from '../../../../../.storybook/preview';
import { RangeSlider } from './RangeSlider';
import { Slider } from './Slider';
const meta = preview.meta({
title: 'Backstage UI/RangeSlider',
component: RangeSlider,
title: 'Backstage UI/Slider',
component: Slider,
});
export const Default = meta.story({
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],
showValueLabel: true,
},
});
@@ -36,7 +52,6 @@ export const WithCustomRange = meta.story({
maxValue: 40,
defaultValue: [0, 20],
step: 5,
showValueLabel: true,
},
});
@@ -47,8 +62,11 @@ export const WithFormattedValues = meta.story({
maxValue: 10000,
defaultValue: [2000, 8000],
step: 100,
showValueLabel: true,
formatValue: (value: number) => `$${value.toLocaleString()}`,
formatOptions: {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
},
},
});
@@ -59,7 +77,6 @@ export const WithDescription = meta.story({
minValue: 0,
maxValue: 100,
defaultValue: [18, 65],
showValueLabel: true,
},
});
@@ -68,7 +85,6 @@ export const Required = meta.story({
label: 'Score Range',
defaultValue: [20, 80],
isRequired: true,
showValueLabel: true,
},
});
@@ -77,46 +93,29 @@ export const Disabled = meta.story({
label: 'Disabled Range',
defaultValue: [30, 70],
isDisabled: true,
showValueLabel: true,
},
});
export const WithSteps = meta.story({
args: {
label: 'Rating Range',
label: 'Rating',
minValue: 0,
maxValue: 5,
step: 0.5,
defaultValue: [1.5, 4],
showValueLabel: true,
formatValue: (value: number) => `${value}`,
defaultValue: 3.5,
},
});
export const SmallRange = meta.story({
export const Percentage = 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] || '';
label: 'Completion',
minValue: 0,
maxValue: 100,
defaultValue: 65,
formatOptions: {
style: 'percent',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
},
},
});
@@ -0,0 +1,154 @@
/*
* 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 { SliderProps } from './types';
import { useDefinition } from '../../hooks/useDefinition';
import { SliderDefinition } from './definition';
import styles from './Slider.module.css';
function SliderImpl<T extends number | number[]>(
props: SliderProps<T>,
ref: React.ForwardedRef<HTMLDivElement>,
) {
const {
label,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
description,
secondaryLabel,
defaultValue,
value,
...restProps
} = props;
const isRequired = (props as any).isRequired;
useEffect(() => {
if (!label && !ariaLabel && !ariaLabelledBy) {
console.warn(
'Slider requires either a visible label, aria-label, or aria-labelledby for accessibility',
);
}
}, [label, ariaLabel, ariaLabelledBy]);
const {
ownProps,
restProps: definitionRest,
dataAttributes,
} = useDefinition(SliderDefinition, restProps);
const { classes, className } = ownProps;
const secondaryLabelText = secondaryLabel || (isRequired ? 'Required' : null);
// Determine if this is a range slider (array value) or single value
const isRange = Array.isArray(defaultValue) || Array.isArray(value);
return (
<AriaSlider
className={clsx(classes.root, styles[classes.root], className)}
{...dataAttributes}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
defaultValue={defaultValue}
value={value}
{...definitionRest}
ref={ref}
>
{label && (
<div className={clsx(classes.header, styles[classes.header])}>
<FieldLabel
label={label}
secondaryLabel={secondaryLabelText}
description={description}
/>
<SliderOutput
className={clsx(classes.output, styles[classes.output])}
>
{({ state }) =>
state.values
.map((_, i) => state.getThumbValueLabel(i))
.join(' ')
}
</SliderOutput>
</div>
)}
<SliderTrack className={clsx(classes.track, styles[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={clsx(classes.trackFill, styles[classes.trackFill])}
style={trackFillStyle}
/>
);
})()}
<SliderThumb
index={0}
className={clsx(classes.thumb, styles[classes.thumb])}
/>
{isRange && (
<SliderThumb
index={1}
className={clsx(classes.thumb, styles[classes.thumb])}
/>
)}
</>
)}
</SliderTrack>
<FieldError />
</AriaSlider>
);
}
/** @public */
export const Slider = forwardRef(SliderImpl) as <T extends number | number[]>(
props: SliderProps<T> & { ref?: React.ForwardedRef<HTMLDivElement> },
) => ReturnType<typeof SliderImpl>;
(Slider as any).displayName = 'Slider';
@@ -15,22 +15,22 @@
*/
import { defineComponent } from '../../hooks/useDefinition';
import type { RangeSliderOwnProps } from './types';
import styles from './RangeSlider.module.css';
import type { SliderOwnProps } from './types';
import styles from './Slider.module.css';
/**
* Component definition for RangeSlider
* Component definition for Slider
* @public
*/
export const RangeSliderDefinition = defineComponent<RangeSliderOwnProps>()({
export const SliderDefinition = defineComponent<SliderOwnProps>()({
styles,
classNames: {
root: 'bui-RangeSlider',
header: 'bui-RangeSliderHeader',
track: 'bui-RangeSliderTrack',
trackFill: 'bui-RangeSliderTrackFill',
thumb: 'bui-RangeSliderThumb',
output: 'bui-RangeSliderOutput',
root: 'bui-Slider',
header: 'bui-SliderHeader',
track: 'bui-SliderTrack',
trackFill: 'bui-SliderTrackFill',
thumb: 'bui-SliderThumb',
output: 'bui-SliderOutput',
},
propDefs: {
className: {},
@@ -14,6 +14,6 @@
* limitations under the License.
*/
export * from './RangeSlider';
export { Slider } from './Slider';
export * from './types';
export { RangeSliderDefinition } from './definition';
export { SliderDefinition } from './definition';
@@ -18,13 +18,13 @@ import type { SliderProps as AriaSliderProps } from 'react-aria-components';
import type { FieldLabelProps } from '../FieldLabel/types';
/** @public */
export interface RangeSliderOwnProps {
export interface SliderOwnProps {
className?: string;
}
/** @public */
export interface RangeSliderProps
extends Omit<AriaSliderProps<[number, number]>, 'children'>,
export interface SliderProps<T extends number | number[]>
extends Omit<AriaSliderProps<T>, 'children'>,
Omit<
FieldLabelProps,
| 'htmlFor'
@@ -34,15 +34,4 @@ export interface RangeSliderProps
| 'onChange'
| 'slot'
| 'style'
> {
/**
* Whether to show a value label in the header next to the field label
* @defaultValue false
*/
showValueLabel?: boolean;
/**
* Format the value for display
*/
formatValue?: (value: number) => string;
}
> {}
+1 -1
View File
@@ -49,7 +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 { SliderDefinition } from './components/Slider/definition';
export { SearchFieldDefinition } from './components/SearchField/definition';
export { SelectDefinition } from './components/Select/definition';
export { SkeletonDefinition } from './components/Skeleton/definition';
+1 -1
View File
@@ -41,7 +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/Slider';
export * from './components/Table';
export * from './components/TablePagination';
export * from './components/Tabs';