Fix review comments added single slider
Signed-off-by: AmbrishRamachandiran <ambrish.r@infosys.com>
This commit is contained in:
+18
-14
@@ -1,81 +1,85 @@
|
||||
'use client';
|
||||
|
||||
import { RangeSlider } from '../../../../../packages/ui/src/components/RangeSlider/RangeSlider';
|
||||
import { Slider } from '../../../../../packages/ui/src/components/Slider';
|
||||
|
||||
export const SingleValue = () => {
|
||||
return (
|
||||
<Slider label="Volume" minValue={0} maxValue={100} defaultValue={50} />
|
||||
);
|
||||
};
|
||||
|
||||
export const Default = () => {
|
||||
return (
|
||||
<RangeSlider
|
||||
<Slider
|
||||
label="Price Range"
|
||||
minValue={0}
|
||||
maxValue={1000}
|
||||
defaultValue={[200, 800]}
|
||||
showValueLabel
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const WithCustomRange = () => {
|
||||
return (
|
||||
<RangeSlider
|
||||
<Slider
|
||||
label="Temperature (°C)"
|
||||
minValue={-20}
|
||||
maxValue={40}
|
||||
defaultValue={[0, 20]}
|
||||
step={5}
|
||||
showValueLabel
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const WithFormattedValues = () => {
|
||||
return (
|
||||
<RangeSlider
|
||||
<Slider
|
||||
label="Budget"
|
||||
minValue={0}
|
||||
maxValue={10000}
|
||||
defaultValue={[2000, 8000]}
|
||||
step={100}
|
||||
showValueLabel
|
||||
formatValue={(value: number) => `$${value.toLocaleString()}`}
|
||||
formatOptions={{
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 0,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const WithDescription = () => {
|
||||
return (
|
||||
<RangeSlider
|
||||
<Slider
|
||||
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
|
||||
<Slider
|
||||
label="Score Range"
|
||||
minValue={0}
|
||||
maxValue={100}
|
||||
defaultValue={[20, 80]}
|
||||
isRequired
|
||||
showValueLabel
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Disabled = () => {
|
||||
return (
|
||||
<RangeSlider
|
||||
<Slider
|
||||
label="Disabled Range"
|
||||
minValue={0}
|
||||
maxValue={100}
|
||||
defaultValue={[30, 70]}
|
||||
isDisabled
|
||||
showValueLabel
|
||||
/>
|
||||
);
|
||||
};
|
||||
+21
-8
@@ -2,9 +2,10 @@ 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 { sliderPropDefs } from './props-definition';
|
||||
import {
|
||||
snippetUsage,
|
||||
singleValueSnippet,
|
||||
defaultSnippet,
|
||||
withCustomRangeSnippet,
|
||||
withFormattedValuesSnippet,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
disabledSnippet,
|
||||
} from './snippets';
|
||||
import {
|
||||
SingleValue,
|
||||
Default,
|
||||
WithCustomRange,
|
||||
WithFormattedValues,
|
||||
@@ -23,15 +25,15 @@ import {
|
||||
import { PageTitle } from '@/components/PageTitle';
|
||||
import { Theming } from '@/components/Theming';
|
||||
import { ChangelogComponent } from '@/components/ChangelogComponent';
|
||||
import { RangeSliderDefinition } from '../../../utils/definitions';
|
||||
import { SliderDefinition } 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."
|
||||
title="Slider"
|
||||
description="A slider for selecting numeric values, supporting both single values and ranges with customizable formatting and validation."
|
||||
/>
|
||||
|
||||
<Snippet align="center" py={4} preview={<Default />} code={defaultSnippet} />
|
||||
@@ -42,12 +44,23 @@ export const reactAriaUrls = {
|
||||
|
||||
## API reference
|
||||
|
||||
<PropsTable data={rangeSliderPropDefs} />
|
||||
<PropsTable data={sliderPropDefs} />
|
||||
|
||||
<ReactAriaLink component="Slider" href={reactAriaUrls.slider} />
|
||||
|
||||
## Examples
|
||||
|
||||
### Single value
|
||||
|
||||
Use a single number as the default value to create a single-thumb slider.
|
||||
|
||||
<Snippet
|
||||
align="center"
|
||||
py={4}
|
||||
preview={<SingleValue />}
|
||||
code={singleValueSnippet}
|
||||
/>
|
||||
|
||||
### Custom range
|
||||
|
||||
Define custom minimum, maximum, and step values for specific use cases.
|
||||
@@ -61,7 +74,7 @@ Define custom minimum, maximum, and step values for specific use cases.
|
||||
|
||||
### Formatted values
|
||||
|
||||
Use the `formatValue` prop to customize how values are displayed.
|
||||
Use the `formatOptions` prop with standard Intl.NumberFormat options to customize how values are displayed.
|
||||
|
||||
<Snippet
|
||||
align="center"
|
||||
@@ -91,6 +104,6 @@ Mark a field as required to show a "Required" indicator in the label.
|
||||
|
||||
<Snippet align="center" py={4} preview={<Disabled />} code={disabledSnippet} />
|
||||
|
||||
<Theming definition={RangeSliderDefinition} />
|
||||
<Theming definition={SliderDefinition} />
|
||||
|
||||
<ChangelogComponent component="range-slider" />
|
||||
<ChangelogComponent component="slider" />
|
||||
+16
-22
@@ -1,10 +1,10 @@
|
||||
import { classNamePropDefs, stylePropDefs } from '@/utils/propDefs';
|
||||
import type { PropDef } from '@/utils/propDefs';
|
||||
|
||||
export const rangeSliderPropDefs: Record<string, PropDef> = {
|
||||
export const sliderPropDefs: Record<string, PropDef> = {
|
||||
label: {
|
||||
type: 'string',
|
||||
description: 'The label text for the range slider.',
|
||||
description: 'The label text for the slider.',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
@@ -22,12 +22,12 @@ export const rangeSliderPropDefs: Record<string, PropDef> = {
|
||||
},
|
||||
minValue: {
|
||||
type: 'number',
|
||||
description: 'The minimum value of the slider range.',
|
||||
description: 'The minimum value of the slider.',
|
||||
default: '0',
|
||||
},
|
||||
maxValue: {
|
||||
type: 'number',
|
||||
description: 'The maximum value of the slider range.',
|
||||
description: 'The maximum value of the slider.',
|
||||
default: '100',
|
||||
},
|
||||
step: {
|
||||
@@ -37,38 +37,32 @@ export const rangeSliderPropDefs: Record<string, PropDef> = {
|
||||
},
|
||||
value: {
|
||||
type: 'enum',
|
||||
values: ['[number, number]'],
|
||||
values: ['number', '[number, number]'],
|
||||
description:
|
||||
'Controlled value as an array [min, max]. Use with onChange for controlled behavior.',
|
||||
'Controlled value. Use a single number for a single-thumb slider, or an array [min, max] for a range slider. Use with onChange for controlled behavior.',
|
||||
},
|
||||
defaultValue: {
|
||||
type: 'enum',
|
||||
values: ['[number, number]'],
|
||||
description: 'Initial value as an array [min, max] for uncontrolled usage.',
|
||||
default: '[minValue, maxValue]',
|
||||
values: ['number', '[number, number]'],
|
||||
description:
|
||||
'Initial value for uncontrolled usage. Use a single number for a single-thumb slider, or an array [min, max] for a range slider.',
|
||||
default: 'minValue or [minValue, maxValue]',
|
||||
},
|
||||
onChange: {
|
||||
type: 'enum',
|
||||
values: ['(value: [number, number]) => void'],
|
||||
description: 'Called when the slider range changes.',
|
||||
values: ['(value: number | [number, number]) => void'],
|
||||
description: 'Called when the slider value changes.',
|
||||
},
|
||||
onChangeEnd: {
|
||||
type: 'enum',
|
||||
values: ['(value: [number, number]) => void'],
|
||||
values: ['(value: number | [number, number]) => void'],
|
||||
description:
|
||||
'Called when the user stops dragging, useful for triggering actions only on final values.',
|
||||
},
|
||||
showValueLabel: {
|
||||
type: 'boolean',
|
||||
formatOptions: {
|
||||
type: 'object',
|
||||
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).',
|
||||
'Intl.NumberFormat options for formatting the displayed value (e.g., { style: "currency", currency: "USD" }).',
|
||||
},
|
||||
isDisabled: {
|
||||
type: 'boolean',
|
||||
+20
-15
@@ -1,62 +1,67 @@
|
||||
export const snippetUsage = `import { RangeSlider } from '@backstage/ui';
|
||||
export const snippetUsage = `import { Slider } from '@backstage/ui';
|
||||
|
||||
<RangeSlider
|
||||
<Slider
|
||||
label="My Range"
|
||||
minValue={0}
|
||||
maxValue={100}
|
||||
defaultValue={[25, 75]}
|
||||
/>`;
|
||||
|
||||
export const defaultSnippet = `<RangeSlider
|
||||
export const singleValueSnippet = `<Slider
|
||||
label="Volume"
|
||||
minValue={0}
|
||||
maxValue={100}
|
||||
defaultValue={50}
|
||||
/>`;
|
||||
|
||||
export const defaultSnippet = `<Slider
|
||||
label="Price Range"
|
||||
minValue={0}
|
||||
maxValue={1000}
|
||||
defaultValue={[200, 800]}
|
||||
showValueLabel
|
||||
/>`;
|
||||
|
||||
export const withCustomRangeSnippet = `<RangeSlider
|
||||
export const withCustomRangeSnippet = `<Slider
|
||||
label="Temperature (°C)"
|
||||
minValue={-20}
|
||||
maxValue={40}
|
||||
defaultValue={[0, 20]}
|
||||
step={5}
|
||||
showValueLabel
|
||||
/>`;
|
||||
|
||||
export const withFormattedValuesSnippet = `<RangeSlider
|
||||
export const withFormattedValuesSnippet = `<Slider
|
||||
label="Budget"
|
||||
minValue={0}
|
||||
maxValue={10000}
|
||||
defaultValue={[2000, 8000]}
|
||||
step={100}
|
||||
showValueLabel
|
||||
formatValue={(value: number) => \`$\${value.toLocaleString()}\`}
|
||||
formatOptions={{
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 0,
|
||||
}}
|
||||
/>`;
|
||||
|
||||
export const withDescriptionSnippet = `<RangeSlider
|
||||
export const withDescriptionSnippet = `<Slider
|
||||
label="Age Range"
|
||||
description="Select the age range for your target audience"
|
||||
minValue={0}
|
||||
maxValue={100}
|
||||
defaultValue={[18, 65]}
|
||||
showValueLabel
|
||||
/>`;
|
||||
|
||||
export const requiredSnippet = `<RangeSlider
|
||||
export const requiredSnippet = `<Slider
|
||||
label="Score Range"
|
||||
minValue={0}
|
||||
maxValue={100}
|
||||
defaultValue={[20, 80]}
|
||||
isRequired
|
||||
showValueLabel
|
||||
/>`;
|
||||
|
||||
export const disabledSnippet = `<RangeSlider
|
||||
export const disabledSnippet = `<Slider
|
||||
label="Disabled Range"
|
||||
minValue={0}
|
||||
maxValue={100}
|
||||
defaultValue={[30, 70]}
|
||||
isDisabled
|
||||
showValueLabel
|
||||
/>`;
|
||||
@@ -86,8 +86,8 @@ export const components: Page[] = [
|
||||
slug: 'radio-group',
|
||||
},
|
||||
{
|
||||
title: 'RangeSlider',
|
||||
slug: 'range-slider',
|
||||
title: 'Slider',
|
||||
slug: 'slider',
|
||||
},
|
||||
{
|
||||
title: 'SearchField',
|
||||
|
||||
+48
-47
@@ -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';
|
||||
+33
-19
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
-38
@@ -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';
|
||||
+10
-10
@@ -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: {},
|
||||
+2
-2
@@ -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';
|
||||
+4
-15
@@ -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;
|
||||
}
|
||||
> {}
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user