diff --git a/.changeset/cool-shoes-hide.md b/.changeset/cool-shoes-hide.md new file mode 100644 index 0000000000..738b590954 --- /dev/null +++ b/.changeset/cool-shoes-hide.md @@ -0,0 +1,7 @@ +--- +'@backstage/ui': patch +--- + +Added RangeSlider component for selecting numeric ranges. + +Affected components: RangeSlider diff --git a/docs-ui/src/app/components/range-slider/components.tsx b/docs-ui/src/app/components/range-slider/components.tsx new file mode 100644 index 0000000000..f3b901a3a4 --- /dev/null +++ b/docs-ui/src/app/components/range-slider/components.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { RangeSlider } from '../../../../../packages/ui/src/components/RangeSlider/RangeSlider'; + +export const Default = () => { + return ( + + ); +}; + +export const WithCustomRange = () => { + return ( + + ); +}; + +export const WithFormattedValues = () => { + return ( + `$${value.toLocaleString()}`} + /> + ); +}; + +export const WithDescription = () => { + return ( + + ); +}; + +export const Required = () => { + return ( + + ); +}; + +export const Disabled = () => { + return ( + + ); +}; diff --git a/docs-ui/src/app/components/range-slider/page.mdx b/docs-ui/src/app/components/range-slider/page.mdx new file mode 100644 index 0000000000..ded00c8878 --- /dev/null +++ b/docs-ui/src/app/components/range-slider/page.mdx @@ -0,0 +1,101 @@ +import { PropsTable } from '@/components/PropsTable'; +import { Snippet } from '@/components/Snippet'; +import { CodeBlock } from '@/components/CodeBlock'; +import { ReactAriaLink } from '@/components/ReactAriaLink'; +import { rangeSliderPropDefs } from './props-definition'; +import { + snippetUsage, + defaultSnippet, + withCustomRangeSnippet, + withFormattedValuesSnippet, + withDescriptionSnippet, + requiredSnippet, + disabledSnippet, +} from './snippets'; +import { + Default, + WithCustomRange, + WithFormattedValues, + WithDescription, + Required, + Disabled, +} from './components'; +import { PageTitle } from '@/components/PageTitle'; +import { Theming } from '@/components/Theming'; +import { ChangelogComponent } from '@/components/ChangelogComponent'; +import { RangeSliderDefinition } from '../../../utils/definitions'; + +export const reactAriaUrls = { + slider: 'https://react-spectrum.adobe.com/react-aria/Slider.html', +}; + + + +} code={defaultSnippet} /> + +## Usage + + + +## API reference + + + + + +## Examples + +### Custom range + +Define custom minimum, maximum, and step values for specific use cases. + +} + code={withCustomRangeSnippet} +/> + +### Formatted values + +Use the `formatValue` prop to customize how values are displayed. + +} + code={withFormattedValuesSnippet} +/> + +### With description + +Add helpful context with a description below the label. + +} + code={withDescriptionSnippet} +/> + +### Required + +Mark a field as required to show a "Required" indicator in the label. + +} code={requiredSnippet} /> + +### Disabled + +} + code={disabledSnippet} +/> + + + + diff --git a/docs-ui/src/app/components/range-slider/props-definition.ts b/docs-ui/src/app/components/range-slider/props-definition.ts new file mode 100644 index 0000000000..90ca2c64a6 --- /dev/null +++ b/docs-ui/src/app/components/range-slider/props-definition.ts @@ -0,0 +1,98 @@ +import { classNamePropDefs, stylePropDefs } from '@/utils/propDefs'; +import type { PropDef } from '@/utils/propDefs'; + +export const rangeSliderPropDefs: Record = { + label: { + type: 'string', + description: 'The label text for the range slider.', + }, + description: { + type: 'string', + description: 'Additional description text displayed below the label.', + }, + secondaryLabel: { + type: 'string', + description: + 'Optional secondary label displayed next to the main label (e.g., "Optional").', + }, + isRequired: { + type: 'boolean', + description: + 'Whether the field is required. Displays "Required" in the label if true.', + }, + minValue: { + type: 'number', + description: 'The minimum value of the slider range.', + default: '0', + }, + maxValue: { + type: 'number', + description: 'The maximum value of the slider range.', + default: '100', + }, + step: { + type: 'number', + description: 'The step increment for slider values.', + default: '1', + }, + value: { + type: 'enum', + values: ['number[]'], + description: + 'Controlled value as an array [min, max]. Use with onChange for controlled behavior.', + }, + defaultValue: { + type: 'enum', + values: ['number[]'], + description: 'Initial value as an array [min, max] for uncontrolled usage.', + default: '[0, 100]', + }, + onChange: { + type: 'enum', + values: ['(value: number[]) => void'], + description: 'Called when the slider range changes.', + }, + onChangeEnd: { + type: 'enum', + values: ['(value: number[]) => void'], + description: + 'Called when the user stops dragging, useful for triggering actions only on final values.', + }, + showValueLabel: { + type: 'boolean', + description: + 'Whether to display the current range values above the slider.', + default: 'false', + }, + formatValue: { + type: 'enum', + values: ['(value: number) => string'], + description: + 'Custom formatter function for displaying values (e.g., adding currency symbols).', + }, + isDisabled: { + type: 'boolean', + description: 'Prevents user interaction when true.', + }, + orientation: { + type: 'enum', + values: ['horizontal', 'vertical'], + description: 'The orientation of the slider.', + default: 'horizontal', + }, + name: { + type: 'string', + description: 'Form field name for form submission.', + }, + 'aria-label': { + type: 'string', + description: + 'Accessible label for screen readers when no visible label is provided.', + }, + 'aria-labelledby': { + type: 'string', + description: 'ID of an element that labels the slider for accessibility.', + }, + ...classNamePropDefs, + ...stylePropDefs, +}; diff --git a/docs-ui/src/app/components/range-slider/snippets.ts b/docs-ui/src/app/components/range-slider/snippets.ts new file mode 100644 index 0000000000..bf1776a336 --- /dev/null +++ b/docs-ui/src/app/components/range-slider/snippets.ts @@ -0,0 +1,62 @@ +export const snippetUsage = `import { RangeSlider } from '@backstage/ui'; + +`; + +export const defaultSnippet = ``; + +export const withCustomRangeSnippet = ``; + +export const withFormattedValuesSnippet = ` \`$\${value.toLocaleString()}\`} +/>`; + +export const withDescriptionSnippet = ``; + +export const requiredSnippet = ``; + +export const disabledSnippet = ``; diff --git a/docs-ui/src/utils/data.ts b/docs-ui/src/utils/data.ts index a1ef483785..4ad0a8acf1 100644 --- a/docs-ui/src/utils/data.ts +++ b/docs-ui/src/utils/data.ts @@ -85,6 +85,11 @@ export const components: Page[] = [ title: 'RadioGroup', slug: 'radio-group', }, + { + title: 'RangeSlider', + slug: 'range-slider', + status: 'new', + }, { title: 'SearchField', slug: 'search-field', diff --git a/packages/ui/src/components/RangeSlider/RangeSlider.module.css b/packages/ui/src/components/RangeSlider/RangeSlider.module.css new file mode 100644 index 0000000000..52524d50f3 --- /dev/null +++ b/packages/ui/src/components/RangeSlider/RangeSlider.module.css @@ -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); + } + } +} diff --git a/packages/ui/src/components/RangeSlider/RangeSlider.stories.tsx b/packages/ui/src/components/RangeSlider/RangeSlider.stories.tsx new file mode 100644 index 0000000000..6040f72dff --- /dev/null +++ b/packages/ui/src/components/RangeSlider/RangeSlider.stories.tsx @@ -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] || ''; + }, + }, +}); diff --git a/packages/ui/src/components/RangeSlider/RangeSlider.tsx b/packages/ui/src/components/RangeSlider/RangeSlider.tsx new file mode 100644 index 0000000000..fd9f048a86 --- /dev/null +++ b/packages/ui/src/components/RangeSlider/RangeSlider.tsx @@ -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( + (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 ( + +
+ + {showValueLabel && ( + + {({ state }) => { + const values = state.values; + if (values.length === 2) { + return `${formatValue(values[0])} - ${formatValue( + values[1], + )}`; + } + return formatValue(values[0]); + }} + + )} +
+ + {({ state }) => ( + <> +
+ + + + )} + + + + ); + }, +); + +RangeSlider.displayName = 'RangeSlider'; diff --git a/packages/ui/src/components/RangeSlider/definition.ts b/packages/ui/src/components/RangeSlider/definition.ts new file mode 100644 index 0000000000..3efbb78456 --- /dev/null +++ b/packages/ui/src/components/RangeSlider/definition.ts @@ -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; diff --git a/packages/ui/src/components/RangeSlider/index.ts b/packages/ui/src/components/RangeSlider/index.ts new file mode 100644 index 0000000000..72dde65c8f --- /dev/null +++ b/packages/ui/src/components/RangeSlider/index.ts @@ -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'; diff --git a/packages/ui/src/components/RangeSlider/types.ts b/packages/ui/src/components/RangeSlider/types.ts new file mode 100644 index 0000000000..3054a2ebbe --- /dev/null +++ b/packages/ui/src/components/RangeSlider/types.ts @@ -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, 'children'>, + Omit { + /** + * 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; +} diff --git a/packages/ui/src/definitions.ts b/packages/ui/src/definitions.ts index 59003e8070..73538b7424 100644 --- a/packages/ui/src/definitions.ts +++ b/packages/ui/src/definitions.ts @@ -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'; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index c70aa9d325..1751d760e8 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -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';