From 8d79835cc0b87c84b7b3b7725596444bddd2d898 Mon Sep 17 00:00:00 2001 From: AmbrishRamachandiran Date: Wed, 4 Mar 2026 16:21:26 +0530 Subject: [PATCH 01/24] BUI - Add new range slider component Signed-off-by: AmbrishRamachandiran --- .changeset/cool-shoes-hide.md | 7 + .../components/range-slider/components.tsx | 81 ++++++++++ .../src/app/components/range-slider/page.mdx | 101 +++++++++++++ .../range-slider/props-definition.ts | 98 ++++++++++++ .../app/components/range-slider/snippets.ts | 62 ++++++++ docs-ui/src/utils/data.ts | 5 + .../RangeSlider/RangeSlider.module.css | 123 +++++++++++++++ .../RangeSlider/RangeSlider.stories.tsx | 122 +++++++++++++++ .../components/RangeSlider/RangeSlider.tsx | 140 ++++++++++++++++++ .../src/components/RangeSlider/definition.ts | 34 +++++ .../ui/src/components/RangeSlider/index.ts | 19 +++ .../ui/src/components/RangeSlider/types.ts | 39 +++++ packages/ui/src/definitions.ts | 1 + packages/ui/src/index.ts | 1 + 14 files changed, 833 insertions(+) create mode 100644 .changeset/cool-shoes-hide.md create mode 100644 docs-ui/src/app/components/range-slider/components.tsx create mode 100644 docs-ui/src/app/components/range-slider/page.mdx create mode 100644 docs-ui/src/app/components/range-slider/props-definition.ts create mode 100644 docs-ui/src/app/components/range-slider/snippets.ts create mode 100644 packages/ui/src/components/RangeSlider/RangeSlider.module.css create mode 100644 packages/ui/src/components/RangeSlider/RangeSlider.stories.tsx create mode 100644 packages/ui/src/components/RangeSlider/RangeSlider.tsx create mode 100644 packages/ui/src/components/RangeSlider/definition.ts create mode 100644 packages/ui/src/components/RangeSlider/index.ts create mode 100644 packages/ui/src/components/RangeSlider/types.ts 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'; From 53b2334152eabe7f808e1840a3d64db4109e4103 Mon Sep 17 00:00:00 2001 From: AmbrishRamachandiran Date: Wed, 4 Mar 2026 16:43:21 +0530 Subject: [PATCH 02/24] Fix review comments Signed-off-by: AmbrishRamachandiran --- .../components/RangeSlider/RangeSlider.tsx | 82 ++++++++++++------- .../ui/src/components/RangeSlider/types.ts | 4 +- 2 files changed, 56 insertions(+), 30 deletions(-) diff --git a/packages/ui/src/components/RangeSlider/RangeSlider.tsx b/packages/ui/src/components/RangeSlider/RangeSlider.tsx index fd9f048a86..ef100dda65 100644 --- a/packages/ui/src/components/RangeSlider/RangeSlider.tsx +++ b/packages/ui/src/components/RangeSlider/RangeSlider.tsx @@ -46,14 +46,30 @@ export const RangeSlider = forwardRef( } }, [label, ariaLabel, ariaLabelledBy]); + useEffect(() => { + const valueArray = props.value ?? props.defaultValue; + if (valueArray && valueArray.length !== 2) { + console.error( + `RangeSlider requires exactly 2 values [min, max], but received ${valueArray.length} values`, + ); + } + }, [props.value, props.defaultValue]); + + const minValue = props.minValue ?? 0; + const maxValue = props.maxValue ?? 100; + const { + defaultValue = [minValue, maxValue] as [number, number], + ...propsWithoutDefault + } = props; + const { classNames, dataAttributes, style, cleanedProps } = useStyles( RangeSliderDefinition, { - minValue: 0, - maxValue: 100, + minValue, + maxValue, step: 1, - defaultValue: [0, 100], - ...props, + defaultValue, + ...propsWithoutDefault, }, ); @@ -106,30 +122,40 @@ export const RangeSlider = forwardRef( - {({ state }) => ( - <> -
- - - - )} + {({ 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 ( + <> +
+ + + + ); + }} diff --git a/packages/ui/src/components/RangeSlider/types.ts b/packages/ui/src/components/RangeSlider/types.ts index 3054a2ebbe..ad93f7c9d4 100644 --- a/packages/ui/src/components/RangeSlider/types.ts +++ b/packages/ui/src/components/RangeSlider/types.ts @@ -19,10 +19,10 @@ import type { FieldLabelProps } from '../FieldLabel/types'; /** @public */ export interface RangeSliderProps - extends Omit, 'children'>, + extends Omit, 'children'>, Omit { /** - * Whether to show value labels above the thumbs + * Whether to show a value label in the header next to the field label * @defaultValue false */ showValueLabel?: boolean; From 5f5dcf9fead9f9832ffa74a66cf9c1a7e23f6f9c Mon Sep 17 00:00:00 2001 From: AmbrishRamachandiran Date: Wed, 4 Mar 2026 16:58:56 +0530 Subject: [PATCH 03/24] Fix review comments Signed-off-by: AmbrishRamachandiran --- .changeset/cool-shoes-hide.md | 2 +- packages/ui/src/components/RangeSlider/RangeSlider.module.css | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/cool-shoes-hide.md b/.changeset/cool-shoes-hide.md index 738b590954..ef7da1597c 100644 --- a/.changeset/cool-shoes-hide.md +++ b/.changeset/cool-shoes-hide.md @@ -4,4 +4,4 @@ Added RangeSlider component for selecting numeric ranges. -Affected components: RangeSlider +**Affected components:** RangeSlider diff --git a/packages/ui/src/components/RangeSlider/RangeSlider.module.css b/packages/ui/src/components/RangeSlider/RangeSlider.module.css index 52524d50f3..76e44f74f6 100644 --- a/packages/ui/src/components/RangeSlider/RangeSlider.module.css +++ b/packages/ui/src/components/RangeSlider/RangeSlider.module.css @@ -98,7 +98,7 @@ transition: all 200ms; &[data-focus-visible] { - outline: 2px solid var(--bui-bg-solid); + outline: 2px solid var(--bui-ring); outline-offset: 2px; } From aeb2b532769b681387cf9c400af934fbafe1e854 Mon Sep 17 00:00:00 2001 From: AmbrishRamachandiran Date: Wed, 4 Mar 2026 17:18:57 +0530 Subject: [PATCH 04/24] Fix review comments Signed-off-by: AmbrishRamachandiran --- .../src/app/components/range-slider/props-definition.ts | 8 ++++---- .../ui/src/components/RangeSlider/RangeSlider.module.css | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs-ui/src/app/components/range-slider/props-definition.ts b/docs-ui/src/app/components/range-slider/props-definition.ts index 90ca2c64a6..833fc7097f 100644 --- a/docs-ui/src/app/components/range-slider/props-definition.ts +++ b/docs-ui/src/app/components/range-slider/props-definition.ts @@ -37,24 +37,24 @@ export const rangeSliderPropDefs: Record = { }, value: { type: 'enum', - values: ['number[]'], + values: ['[number, number]'], description: 'Controlled value as an array [min, max]. Use with onChange for controlled behavior.', }, defaultValue: { type: 'enum', - values: ['number[]'], + values: ['[number, number]'], description: 'Initial value as an array [min, max] for uncontrolled usage.', default: '[0, 100]', }, onChange: { type: 'enum', - values: ['(value: number[]) => void'], + values: ['(value: [number, number]) => void'], description: 'Called when the slider range changes.', }, onChangeEnd: { type: 'enum', - values: ['(value: number[]) => void'], + values: ['(value: [number, number]) => void'], description: 'Called when the user stops dragging, useful for triggering actions only on final values.', }, diff --git a/packages/ui/src/components/RangeSlider/RangeSlider.module.css b/packages/ui/src/components/RangeSlider/RangeSlider.module.css index 76e44f74f6..085d69946a 100644 --- a/packages/ui/src/components/RangeSlider/RangeSlider.module.css +++ b/packages/ui/src/components/RangeSlider/RangeSlider.module.css @@ -82,6 +82,7 @@ height: auto; left: 0; right: 0; + top: auto; } } From 445fc101ac0553a63161a0befe42ef3d51527a85 Mon Sep 17 00:00:00 2001 From: AmbrishRamachandiran Date: Wed, 4 Mar 2026 17:53:35 +0530 Subject: [PATCH 05/24] Fix review comments Signed-off-by: AmbrishRamachandiran --- docs-ui/src/app/components/range-slider/props-definition.ts | 2 +- packages/ui/src/components/RangeSlider/RangeSlider.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs-ui/src/app/components/range-slider/props-definition.ts b/docs-ui/src/app/components/range-slider/props-definition.ts index 833fc7097f..58edf01a39 100644 --- a/docs-ui/src/app/components/range-slider/props-definition.ts +++ b/docs-ui/src/app/components/range-slider/props-definition.ts @@ -45,7 +45,7 @@ export const rangeSliderPropDefs: Record = { type: 'enum', values: ['[number, number]'], description: 'Initial value as an array [min, max] for uncontrolled usage.', - default: '[0, 100]', + default: '[minValue, maxValue]', }, onChange: { type: 'enum', diff --git a/packages/ui/src/components/RangeSlider/RangeSlider.tsx b/packages/ui/src/components/RangeSlider/RangeSlider.tsx index ef100dda65..731bd28c73 100644 --- a/packages/ui/src/components/RangeSlider/RangeSlider.tsx +++ b/packages/ui/src/components/RangeSlider/RangeSlider.tsx @@ -49,7 +49,7 @@ export const RangeSlider = forwardRef( useEffect(() => { const valueArray = props.value ?? props.defaultValue; if (valueArray && valueArray.length !== 2) { - console.error( + console.warn( `RangeSlider requires exactly 2 values [min, max], but received ${valueArray.length} values`, ); } From d32f8282a52013874b4513e92407acfc8740372d Mon Sep 17 00:00:00 2001 From: AmbrishRamachandiran Date: Wed, 4 Mar 2026 18:14:28 +0530 Subject: [PATCH 06/24] Fix review comments Signed-off-by: AmbrishRamachandiran --- .../src/components/RangeSlider/RangeSlider.tsx | 16 ++++++++++------ .../ui/src/components/RangeSlider/definition.ts | 1 + 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/components/RangeSlider/RangeSlider.tsx b/packages/ui/src/components/RangeSlider/RangeSlider.tsx index 731bd28c73..b6ca6a5026 100644 --- a/packages/ui/src/components/RangeSlider/RangeSlider.tsx +++ b/packages/ui/src/components/RangeSlider/RangeSlider.tsx @@ -57,10 +57,12 @@ export const RangeSlider = forwardRef( const minValue = props.minValue ?? 0; const maxValue = props.maxValue ?? 100; - const { - defaultValue = [minValue, maxValue] as [number, number], - ...propsWithoutDefault - } = props; + const uncontrolledDefaultValue = + props.value === undefined + ? ((props.defaultValue ?? + ([minValue, maxValue] as [number, number])) as [number, number]) + : undefined; + const { defaultValue: _ignoredDefault, ...propsWithoutDefault } = props; const { classNames, dataAttributes, style, cleanedProps } = useStyles( RangeSliderDefinition, @@ -68,7 +70,9 @@ export const RangeSlider = forwardRef( minValue, maxValue, step: 1, - defaultValue, + ...(uncontrolledDefaultValue !== undefined + ? { defaultValue: uncontrolledDefaultValue } + : {}), ...propsWithoutDefault, }, ); @@ -97,7 +101,7 @@ export const RangeSlider = forwardRef( {...rest} ref={ref} > -
+
Date: Wed, 4 Mar 2026 18:28:27 +0530 Subject: [PATCH 07/24] Fix review comments Signed-off-by: AmbrishRamachandiran --- .../components/RangeSlider/RangeSlider.tsx | 55 ++++++++++++++----- 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/packages/ui/src/components/RangeSlider/RangeSlider.tsx b/packages/ui/src/components/RangeSlider/RangeSlider.tsx index b6ca6a5026..f6f9835a7f 100644 --- a/packages/ui/src/components/RangeSlider/RangeSlider.tsx +++ b/packages/ui/src/components/RangeSlider/RangeSlider.tsx @@ -46,22 +46,47 @@ export const RangeSlider = forwardRef( } }, [label, ariaLabel, ariaLabelledBy]); - useEffect(() => { - const valueArray = props.value ?? props.defaultValue; - if (valueArray && valueArray.length !== 2) { - console.warn( - `RangeSlider requires exactly 2 values [min, max], but received ${valueArray.length} values`, - ); - } - }, [props.value, props.defaultValue]); - 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]; + + useEffect(() => { + if ( + rawDefaultValue && + (!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}].`, + ); + } + const valueArray = props.value; + if ( + valueArray && + (!Array.isArray(valueArray) || + valueArray.length !== 2 || + typeof valueArray[0] !== 'number' || + typeof valueArray[1] !== 'number') + ) { + console.warn( + `RangeSlider requires exactly 2 numeric values [min, max], but received invalid value.`, + ); + } + }, [props.value, rawDefaultValue, minValue, maxValue]); + const uncontrolledDefaultValue = - props.value === undefined - ? ((props.defaultValue ?? - ([minValue, maxValue] as [number, number])) as [number, number]) - : undefined; + props.value === undefined ? normalizedDefaultValue : undefined; const { defaultValue: _ignoredDefault, ...propsWithoutDefault } = props; const { classNames, dataAttributes, style, cleanedProps } = useStyles( @@ -127,6 +152,10 @@ export const RangeSlider = forwardRef( className={clsx(classNames.track, styles[classNames.track])} > {({ state }) => { + // Safeguard: ensure we have at least 2 values for range slider + if (state.values.length < 2) { + return null; + } const start = state.getThumbPercent(0); const end = state.getThumbPercent(1); const rangePercent = (end - start) * 100; From 6fd571914df85ef44ac62b7f543d1e99b4299297 Mon Sep 17 00:00:00 2001 From: AmbrishRamachandiran Date: Wed, 4 Mar 2026 18:49:57 +0530 Subject: [PATCH 08/24] Fix review comments Signed-off-by: AmbrishRamachandiran --- .../components/RangeSlider/RangeSlider.tsx | 40 ++++++++++++------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/packages/ui/src/components/RangeSlider/RangeSlider.tsx b/packages/ui/src/components/RangeSlider/RangeSlider.tsx index f6f9835a7f..d9baa38814 100644 --- a/packages/ui/src/components/RangeSlider/RangeSlider.tsx +++ b/packages/ui/src/components/RangeSlider/RangeSlider.tsx @@ -59,6 +59,18 @@ export const RangeSlider = forwardRef( ? [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 && @@ -71,23 +83,26 @@ export const RangeSlider = forwardRef( `RangeSlider requires exactly 2 numeric values [min, max], but received invalid defaultValue. Falling back to [${minValue}, ${maxValue}].`, ); } - const valueArray = props.value; if ( - valueArray && - (!Array.isArray(valueArray) || - valueArray.length !== 2 || - typeof valueArray[0] !== 'number' || - typeof valueArray[1] !== 'number') + 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.`, + `RangeSlider requires exactly 2 numeric values [min, max], but received invalid value. Falling back to [${minValue}, ${maxValue}].`, ); } - }, [props.value, rawDefaultValue, minValue, maxValue]); + }, [rawValue, rawDefaultValue, minValue, maxValue]); const uncontrolledDefaultValue = - props.value === undefined ? normalizedDefaultValue : undefined; - const { defaultValue: _ignoredDefault, ...propsWithoutDefault } = props; + normalizedValue === undefined ? normalizedDefaultValue : undefined; + const { + defaultValue: _ignoredDefault, + value: _ignoredValue, + ...propsWithoutDefault + } = props; const { classNames, dataAttributes, style, cleanedProps } = useStyles( RangeSliderDefinition, @@ -98,6 +113,7 @@ export const RangeSlider = forwardRef( ...(uncontrolledDefaultValue !== undefined ? { defaultValue: uncontrolledDefaultValue } : {}), + ...(normalizedValue !== undefined ? { value: normalizedValue } : {}), ...propsWithoutDefault, }, ); @@ -152,10 +168,6 @@ export const RangeSlider = forwardRef( className={clsx(classNames.track, styles[classNames.track])} > {({ state }) => { - // Safeguard: ensure we have at least 2 values for range slider - if (state.values.length < 2) { - return null; - } const start = state.getThumbPercent(0); const end = state.getThumbPercent(1); const rangePercent = (end - start) * 100; From 6676b0e137b03e4866bf43c3525fcc0763f7316f Mon Sep 17 00:00:00 2001 From: AmbrishRamachandiran Date: Wed, 4 Mar 2026 19:08:25 +0530 Subject: [PATCH 09/24] Fix review comments Signed-off-by: AmbrishRamachandiran --- packages/ui/src/components/RangeSlider/RangeSlider.tsx | 6 ++++-- packages/ui/src/components/RangeSlider/types.ts | 5 ----- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/components/RangeSlider/RangeSlider.tsx b/packages/ui/src/components/RangeSlider/RangeSlider.tsx index d9baa38814..7796634a7a 100644 --- a/packages/ui/src/components/RangeSlider/RangeSlider.tsx +++ b/packages/ui/src/components/RangeSlider/RangeSlider.tsx @@ -38,6 +38,9 @@ export const RangeSlider = forwardRef( 'aria-labelledby': ariaLabelledBy, } = props; + // Extract isRequired early since it's inherited from AriaSliderProps + const isRequired = 'isRequired' in props ? props.isRequired : undefined; + useEffect(() => { if (!label && !ariaLabel && !ariaLabelledBy) { console.warn( @@ -73,7 +76,7 @@ export const RangeSlider = forwardRef( useEffect(() => { if ( - rawDefaultValue && + rawDefaultValue !== undefined && (!Array.isArray(rawDefaultValue) || rawDefaultValue.length !== 2 || typeof rawDefaultValue[0] !== 'number' || @@ -122,7 +125,6 @@ export const RangeSlider = forwardRef( className, description, secondaryLabel, - isRequired, showValueLabel = false, formatValue = (val: number) => val.toString(), ...rest diff --git a/packages/ui/src/components/RangeSlider/types.ts b/packages/ui/src/components/RangeSlider/types.ts index ad93f7c9d4..1f09cfc713 100644 --- a/packages/ui/src/components/RangeSlider/types.ts +++ b/packages/ui/src/components/RangeSlider/types.ts @@ -31,9 +31,4 @@ export interface RangeSliderProps * Format the value for display */ formatValue?: (value: number) => string; - - /** - * Whether the slider is required (displays "Required" in the label) - */ - isRequired?: boolean; } From e62d94387f798ae31b4788f6320078ff5ee12f82 Mon Sep 17 00:00:00 2001 From: AmbrishRamachandiran Date: Wed, 4 Mar 2026 19:19:57 +0530 Subject: [PATCH 10/24] Fix review comments Signed-off-by: AmbrishRamachandiran --- .../components/RangeSlider/RangeSlider.tsx | 46 ++++++++++--------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/packages/ui/src/components/RangeSlider/RangeSlider.tsx b/packages/ui/src/components/RangeSlider/RangeSlider.tsx index 7796634a7a..3f29fc8b2d 100644 --- a/packages/ui/src/components/RangeSlider/RangeSlider.tsx +++ b/packages/ui/src/components/RangeSlider/RangeSlider.tsx @@ -144,28 +144,30 @@ export const RangeSlider = forwardRef( {...rest} ref={ref} > -
- - {showValueLabel && ( - - {({ state }) => { - const values = state.values; - if (values.length === 2) { - return `${formatValue(values[0])} - ${formatValue( - values[1], - )}`; - } - return formatValue(values[0]); - }} - - )} -
+ {(label || showValueLabel) && ( +
+ + {showValueLabel && ( + + {({ state }) => { + const values = state.values; + if (values.length === 2) { + return `${formatValue(values[0])} - ${formatValue( + values[1], + )}`; + } + return formatValue(values[0]); + }} + + )} +
+ )} From fd4708212d5d1364fc4c413a28d37c5ba4216009 Mon Sep 17 00:00:00 2001 From: AmbrishRamachandiran Date: Wed, 4 Mar 2026 19:40:59 +0530 Subject: [PATCH 11/24] Fix review comments Signed-off-by: AmbrishRamachandiran --- packages/ui/src/components/RangeSlider/RangeSlider.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui/src/components/RangeSlider/RangeSlider.tsx b/packages/ui/src/components/RangeSlider/RangeSlider.tsx index 3f29fc8b2d..2785fc0eff 100644 --- a/packages/ui/src/components/RangeSlider/RangeSlider.tsx +++ b/packages/ui/src/components/RangeSlider/RangeSlider.tsx @@ -123,6 +123,7 @@ export const RangeSlider = forwardRef( const { className, + label: _ignoredLabel, description, secondaryLabel, showValueLabel = false, From 400175a09d15f87f3a5d5535924a52a10de074bb Mon Sep 17 00:00:00 2001 From: Ambrish R Date: Wed, 4 Mar 2026 19:53:49 +0530 Subject: [PATCH 12/24] Update packages/ui/src/components/RangeSlider/definition.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Ambrish R --- packages/ui/src/components/RangeSlider/definition.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui/src/components/RangeSlider/definition.ts b/packages/ui/src/components/RangeSlider/definition.ts index 72cceb61fd..29f605978a 100644 --- a/packages/ui/src/components/RangeSlider/definition.ts +++ b/packages/ui/src/components/RangeSlider/definition.ts @@ -31,5 +31,6 @@ export const RangeSliderDefinition = { }, dataAttributes: { disabled: [true, false] as const, + orientation: ['horizontal', 'vertical'] as const, }, } as const satisfies ComponentDefinition; From a5c4e902ed1219c7045445a5c753c477dc54b7bb Mon Sep 17 00:00:00 2001 From: AmbrishRamachandiran Date: Thu, 5 Mar 2026 12:06:08 +0530 Subject: [PATCH 13/24] Fix review comments Signed-off-by: AmbrishRamachandiran --- docs-ui/src/utils/data.ts | 1 - packages/ui/src/components/RangeSlider/RangeSlider.tsx | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/docs-ui/src/utils/data.ts b/docs-ui/src/utils/data.ts index 4ad0a8acf1..66ec17e3ba 100644 --- a/docs-ui/src/utils/data.ts +++ b/docs-ui/src/utils/data.ts @@ -88,7 +88,6 @@ export const components: Page[] = [ { title: 'RangeSlider', slug: 'range-slider', - status: 'new', }, { title: 'SearchField', diff --git a/packages/ui/src/components/RangeSlider/RangeSlider.tsx b/packages/ui/src/components/RangeSlider/RangeSlider.tsx index 2785fc0eff..4cdb78ca01 100644 --- a/packages/ui/src/components/RangeSlider/RangeSlider.tsx +++ b/packages/ui/src/components/RangeSlider/RangeSlider.tsx @@ -38,8 +38,8 @@ export const RangeSlider = forwardRef( 'aria-labelledby': ariaLabelledBy, } = props; - // Extract isRequired early since it's inherited from AriaSliderProps - const isRequired = 'isRequired' in props ? props.isRequired : undefined; + // Extract isRequired from props (inherited from AriaSliderProps) + const isRequired = (props as any).isRequired; useEffect(() => { if (!label && !ariaLabel && !ariaLabelledBy) { From bc191975708f5c65caa89effb93aaaa5b2633a70 Mon Sep 17 00:00:00 2001 From: AmbrishRamachandiran Date: Mon, 9 Mar 2026 23:11:33 +0530 Subject: [PATCH 14/24] Fix prettier and api report Signed-off-by: AmbrishRamachandiran --- .../src/app/components/range-slider/page.mdx | 7 +-- packages/ui/report.api.md | 47 +++++++++++++++++++ .../RangeSlider/RangeSlider.module.css | 7 +-- .../components/RangeSlider/RangeSlider.tsx | 28 +++++------ .../src/components/RangeSlider/definition.ts | 14 +++--- .../ui/src/components/RangeSlider/types.ts | 16 ++++++- 6 files changed, 84 insertions(+), 35 deletions(-) diff --git a/docs-ui/src/app/components/range-slider/page.mdx b/docs-ui/src/app/components/range-slider/page.mdx index ded00c8878..93d0eac782 100644 --- a/docs-ui/src/app/components/range-slider/page.mdx +++ b/docs-ui/src/app/components/range-slider/page.mdx @@ -89,12 +89,7 @@ Mark a field as required to show a "Required" indicator in the label. ### Disabled -} - code={disabledSnippet} -/> +} code={disabledSnippet} /> diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index 7e8c8833ee..c9af330ffb 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -39,6 +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 { 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'; @@ -1967,6 +1968,52 @@ export interface RadioProps extends RadioOwnProps, Omit {} +// @public (undocumented) +export const RangeSlider: ForwardRefExoticComponent< + RangeSliderProps & RefAttributes +>; + +// @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, 'children'>, + Omit< + FieldLabelProps, + | 'htmlFor' + | 'id' + | 'className' + | 'defaultValue' + | 'onChange' + | 'slot' + | 'style' + > { + formatValue?: (value: number) => string; + showValueLabel?: boolean; +} + // @public (undocumented) export type Responsive = T | Partial>; diff --git a/packages/ui/src/components/RangeSlider/RangeSlider.module.css b/packages/ui/src/components/RangeSlider/RangeSlider.module.css index 085d69946a..209f823a19 100644 --- a/packages/ui/src/components/RangeSlider/RangeSlider.module.css +++ b/packages/ui/src/components/RangeSlider/RangeSlider.module.css @@ -92,9 +92,7 @@ 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); + 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; @@ -105,8 +103,7 @@ &[data-dragging] { cursor: grabbing; - box-shadow: - 0 4px 6px -1px rgb(0 0 0 / 0.1), + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); } diff --git a/packages/ui/src/components/RangeSlider/RangeSlider.tsx b/packages/ui/src/components/RangeSlider/RangeSlider.tsx index 4cdb78ca01..f9f1ac1bc0 100644 --- a/packages/ui/src/components/RangeSlider/RangeSlider.tsx +++ b/packages/ui/src/components/RangeSlider/RangeSlider.tsx @@ -25,7 +25,7 @@ import clsx from 'clsx'; import { FieldLabel } from '../FieldLabel'; import { FieldError } from '../FieldError'; import type { RangeSliderProps } from './types'; -import { useStyles } from '../../hooks/useStyles'; +import { useDefinition } from '../../hooks/useDefinition'; import { RangeSliderDefinition } from './definition'; import styles from './RangeSlider.module.css'; @@ -107,7 +107,7 @@ export const RangeSlider = forwardRef( ...propsWithoutDefault } = props; - const { classNames, dataAttributes, style, cleanedProps } = useStyles( + const { ownProps, restProps, dataAttributes } = useDefinition( RangeSliderDefinition, { minValue, @@ -120,16 +120,16 @@ export const RangeSlider = forwardRef( ...propsWithoutDefault, }, ); + const { classes, className } = ownProps; const { - className, label: _ignoredLabel, description, secondaryLabel, showValueLabel = false, formatValue = (val: number) => val.toString(), ...rest - } = cleanedProps; + } = restProps; // If a secondary label is provided, use it. Otherwise, use 'Required' if the field is required. const secondaryLabelText = @@ -137,16 +137,15 @@ export const RangeSlider = forwardRef( return ( {(label || showValueLabel) && ( -
+
( /> {showValueLabel && ( {({ state }) => { const values = state.values; @@ -169,9 +168,7 @@ export const RangeSlider = forwardRef( )}
)} - + {({ state }) => { const start = state.getThumbPercent(0); const end = state.getThumbPercent(1); @@ -189,19 +186,16 @@ export const RangeSlider = forwardRef( return ( <>
); diff --git a/packages/ui/src/components/RangeSlider/definition.ts b/packages/ui/src/components/RangeSlider/definition.ts index 29f605978a..b23e607ede 100644 --- a/packages/ui/src/components/RangeSlider/definition.ts +++ b/packages/ui/src/components/RangeSlider/definition.ts @@ -14,13 +14,16 @@ * limitations under the License. */ -import type { ComponentDefinition } from '../../types'; +import { defineComponent } from '../../hooks/useDefinition'; +import type { RangeSliderOwnProps } from './types'; +import styles from './RangeSlider.module.css'; /** * Component definition for RangeSlider * @public */ -export const RangeSliderDefinition = { +export const RangeSliderDefinition = defineComponent()({ + styles, classNames: { root: 'bui-RangeSlider', header: 'bui-RangeSliderHeader', @@ -29,8 +32,7 @@ export const RangeSliderDefinition = { thumb: 'bui-RangeSliderThumb', output: 'bui-RangeSliderOutput', }, - dataAttributes: { - disabled: [true, false] as const, - orientation: ['horizontal', 'vertical'] as const, + propDefs: { + className: {}, }, -} as const satisfies ComponentDefinition; +}); diff --git a/packages/ui/src/components/RangeSlider/types.ts b/packages/ui/src/components/RangeSlider/types.ts index 1f09cfc713..2ee4105240 100644 --- a/packages/ui/src/components/RangeSlider/types.ts +++ b/packages/ui/src/components/RangeSlider/types.ts @@ -17,10 +17,24 @@ import type { SliderProps as AriaSliderProps } from 'react-aria-components'; import type { FieldLabelProps } from '../FieldLabel/types'; +/** @public */ +export interface RangeSliderOwnProps { + className?: string; +} + /** @public */ export interface RangeSliderProps extends Omit, 'children'>, - Omit { + Omit< + FieldLabelProps, + | 'htmlFor' + | 'id' + | 'className' + | 'defaultValue' + | 'onChange' + | 'slot' + | 'style' + > { /** * Whether to show a value label in the header next to the field label * @defaultValue false From 3ae5a679b20fac54d50462fab2cbcf8c37428f84 Mon Sep 17 00:00:00 2001 From: AmbrishRamachandiran Date: Tue, 10 Mar 2026 10:51:16 +0530 Subject: [PATCH 15/24] Fix review comments added single slider Signed-off-by: AmbrishRamachandiran --- .../{range-slider => slider}/components.tsx | 32 +-- .../{range-slider => slider}/page.mdx | 29 ++- .../props-definition.ts | 38 ++-- .../{range-slider => slider}/snippets.ts | 35 +-- docs-ui/src/utils/data.ts | 4 +- packages/ui/report.api.md | 95 ++++---- .../components/RangeSlider/RangeSlider.tsx | 210 ------------------ .../Slider.module.css} | 52 +++-- .../Slider.stories.tsx} | 75 +++---- packages/ui/src/components/Slider/Slider.tsx | 154 +++++++++++++ .../{RangeSlider => Slider}/definition.ts | 20 +- .../{RangeSlider => Slider}/index.ts | 4 +- .../{RangeSlider => Slider}/types.ts | 19 +- packages/ui/src/definitions.ts | 2 +- packages/ui/src/index.ts | 2 +- 15 files changed, 367 insertions(+), 404 deletions(-) rename docs-ui/src/app/components/{range-slider => slider}/components.tsx (73%) rename docs-ui/src/app/components/{range-slider => slider}/page.mdx (71%) rename docs-ui/src/app/components/{range-slider => slider}/props-definition.ts (64%) rename docs-ui/src/app/components/{range-slider => slider}/snippets.ts (56%) delete mode 100644 packages/ui/src/components/RangeSlider/RangeSlider.tsx rename packages/ui/src/components/{RangeSlider/RangeSlider.module.css => Slider/Slider.module.css} (76%) rename packages/ui/src/components/{RangeSlider/RangeSlider.stories.tsx => Slider/Slider.stories.tsx} (64%) create mode 100644 packages/ui/src/components/Slider/Slider.tsx rename packages/ui/src/components/{RangeSlider => Slider}/definition.ts (63%) rename packages/ui/src/components/{RangeSlider => Slider}/index.ts (88%) rename packages/ui/src/components/{RangeSlider => Slider}/types.ts (71%) diff --git a/docs-ui/src/app/components/range-slider/components.tsx b/docs-ui/src/app/components/slider/components.tsx similarity index 73% rename from docs-ui/src/app/components/range-slider/components.tsx rename to docs-ui/src/app/components/slider/components.tsx index f3b901a3a4..55da7279c1 100644 --- a/docs-ui/src/app/components/range-slider/components.tsx +++ b/docs-ui/src/app/components/slider/components.tsx @@ -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 ( + + ); +}; export const Default = () => { return ( - ); }; export const WithCustomRange = () => { return ( - ); }; export const WithFormattedValues = () => { return ( - `$${value.toLocaleString()}`} + formatOptions={{ + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, + }} /> ); }; 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/slider/page.mdx similarity index 71% rename from docs-ui/src/app/components/range-slider/page.mdx rename to docs-ui/src/app/components/slider/page.mdx index 93d0eac782..c46185f9f6 100644 --- a/docs-ui/src/app/components/range-slider/page.mdx +++ b/docs-ui/src/app/components/slider/page.mdx @@ -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', }; } code={defaultSnippet} /> @@ -42,12 +44,23 @@ export const reactAriaUrls = { ## API reference - + ## Examples +### Single value + +Use a single number as the default value to create a single-thumb slider. + +} + 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. } code={disabledSnippet} /> - + - + diff --git a/docs-ui/src/app/components/range-slider/props-definition.ts b/docs-ui/src/app/components/slider/props-definition.ts similarity index 64% rename from docs-ui/src/app/components/range-slider/props-definition.ts rename to docs-ui/src/app/components/slider/props-definition.ts index 58edf01a39..98bddc3bf9 100644 --- a/docs-ui/src/app/components/range-slider/props-definition.ts +++ b/docs-ui/src/app/components/slider/props-definition.ts @@ -1,10 +1,10 @@ import { classNamePropDefs, stylePropDefs } from '@/utils/propDefs'; import type { PropDef } from '@/utils/propDefs'; -export const rangeSliderPropDefs: Record = { +export const sliderPropDefs: Record = { 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 = { }, 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 = { }, 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', diff --git a/docs-ui/src/app/components/range-slider/snippets.ts b/docs-ui/src/app/components/slider/snippets.ts similarity index 56% rename from docs-ui/src/app/components/range-slider/snippets.ts rename to docs-ui/src/app/components/slider/snippets.ts index bf1776a336..02a4271bd7 100644 --- a/docs-ui/src/app/components/range-slider/snippets.ts +++ b/docs-ui/src/app/components/slider/snippets.ts @@ -1,62 +1,67 @@ -export const snippetUsage = `import { RangeSlider } from '@backstage/ui'; +export const snippetUsage = `import { Slider } from '@backstage/ui'; -`; -export const defaultSnippet = ``; + +export const defaultSnippet = ``; -export const withCustomRangeSnippet = ``; -export const withFormattedValuesSnippet = ` \`$\${value.toLocaleString()}\`} + formatOptions={{ + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, + }} />`; -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 66ec17e3ba..a20a3297ac 100644 --- a/docs-ui/src/utils/data.ts +++ b/docs-ui/src/utils/data.ts @@ -86,8 +86,8 @@ export const components: Page[] = [ slug: 'radio-group', }, { - title: 'RangeSlider', - slug: 'range-slider', + title: 'Slider', + slug: 'slider', }, { title: 'SearchField', diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index c9af330ffb..8dd55be56c 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -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 {} -// @public (undocumented) -export const RangeSlider: ForwardRefExoticComponent< - RangeSliderProps & RefAttributes ->; - -// @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, 'children'>, - Omit< - FieldLabelProps, - | 'htmlFor' - | 'id' - | 'className' - | 'defaultValue' - | 'onChange' - | 'slot' - | 'style' - > { - formatValue?: (value: number) => string; - showValueLabel?: boolean; -} - // @public (undocumented) export type Responsive = T | Partial>; @@ -2204,6 +2158,53 @@ export interface SkeletonProps extends Omit, '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: ( + props: SliderProps & { + ref?: React.ForwardedRef; + }, +) => ReturnType; + +// @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 + extends Omit, 'children'>, + Omit< + FieldLabelProps, + | 'htmlFor' + | 'id' + | 'className' + | 'defaultValue' + | 'onChange' + | 'slot' + | 'style' + > {} + // @public (undocumented) export type SortDescriptor = SortDescriptor_2; diff --git a/packages/ui/src/components/RangeSlider/RangeSlider.tsx b/packages/ui/src/components/RangeSlider/RangeSlider.tsx deleted file mode 100644 index f9f1ac1bc0..0000000000 --- a/packages/ui/src/components/RangeSlider/RangeSlider.tsx +++ /dev/null @@ -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( - (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 ( - - {(label || showValueLabel) && ( -
- - {showValueLabel && ( - - {({ state }) => { - const values = state.values; - if (values.length === 2) { - return `${formatValue(values[0])} - ${formatValue( - values[1], - )}`; - } - return formatValue(values[0]); - }} - - )} -
- )} - - {({ 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 ( - <> -
- - - - ); - }} - - - - ); - }, -); - -RangeSlider.displayName = 'RangeSlider'; diff --git a/packages/ui/src/components/RangeSlider/RangeSlider.module.css b/packages/ui/src/components/Slider/Slider.module.css similarity index 76% rename from packages/ui/src/components/RangeSlider/RangeSlider.module.css rename to packages/ui/src/components/Slider/Slider.module.css index 209f823a19..a84be7a306 100644 --- a/packages/ui/src/components/RangeSlider/RangeSlider.module.css +++ b/packages/ui/src/components/Slider/Slider.module.css @@ -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); } } } diff --git a/packages/ui/src/components/RangeSlider/RangeSlider.stories.tsx b/packages/ui/src/components/Slider/Slider.stories.tsx similarity index 64% rename from packages/ui/src/components/RangeSlider/RangeSlider.stories.tsx rename to packages/ui/src/components/Slider/Slider.stories.tsx index 6040f72dff..b03f45412c 100644 --- a/packages/ui/src/components/RangeSlider/RangeSlider.stories.tsx +++ b/packages/ui/src/components/Slider/Slider.stories.tsx @@ -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, }, }, }); diff --git a/packages/ui/src/components/Slider/Slider.tsx b/packages/ui/src/components/Slider/Slider.tsx new file mode 100644 index 0000000000..3bfb407504 --- /dev/null +++ b/packages/ui/src/components/Slider/Slider.tsx @@ -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( + props: SliderProps, + ref: React.ForwardedRef, +) { + 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 ( + + {label && ( +
+ + + {({ state }) => + state.values + .map((_, i) => state.getThumbValueLabel(i)) + .join(' – ') + } + +
+ )} + + {({ 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 ( +
+ ); + })()} + + {isRange && ( + + )} + + )} + + + + ); +} + +/** @public */ +export const Slider = forwardRef(SliderImpl) as ( + props: SliderProps & { ref?: React.ForwardedRef }, +) => ReturnType; + +(Slider as any).displayName = 'Slider'; diff --git a/packages/ui/src/components/RangeSlider/definition.ts b/packages/ui/src/components/Slider/definition.ts similarity index 63% rename from packages/ui/src/components/RangeSlider/definition.ts rename to packages/ui/src/components/Slider/definition.ts index b23e607ede..ab460c93ba 100644 --- a/packages/ui/src/components/RangeSlider/definition.ts +++ b/packages/ui/src/components/Slider/definition.ts @@ -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()({ +export const SliderDefinition = defineComponent()({ 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: {}, diff --git a/packages/ui/src/components/RangeSlider/index.ts b/packages/ui/src/components/Slider/index.ts similarity index 88% rename from packages/ui/src/components/RangeSlider/index.ts rename to packages/ui/src/components/Slider/index.ts index 72dde65c8f..232f999bb1 100644 --- a/packages/ui/src/components/RangeSlider/index.ts +++ b/packages/ui/src/components/Slider/index.ts @@ -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'; diff --git a/packages/ui/src/components/RangeSlider/types.ts b/packages/ui/src/components/Slider/types.ts similarity index 71% rename from packages/ui/src/components/RangeSlider/types.ts rename to packages/ui/src/components/Slider/types.ts index 2ee4105240..ce58fef748 100644 --- a/packages/ui/src/components/RangeSlider/types.ts +++ b/packages/ui/src/components/Slider/types.ts @@ -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, 'children'>, +export interface SliderProps + extends Omit, '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; -} + > {} diff --git a/packages/ui/src/definitions.ts b/packages/ui/src/definitions.ts index 73538b7424..6a8ca28e29 100644 --- a/packages/ui/src/definitions.ts +++ b/packages/ui/src/definitions.ts @@ -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'; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 1751d760e8..cf02c39bdd 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -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'; From 5d4094c785650a15b88fc7c5641d6075ce20ecb1 Mon Sep 17 00:00:00 2001 From: AmbrishRamachandiran Date: Tue, 10 Mar 2026 11:43:42 +0530 Subject: [PATCH 16/24] Fix review comments added single slider Signed-off-by: AmbrishRamachandiran --- .../src/components/Slider/Slider.module.css | 8 +- .../src/components/Slider/Slider.stories.tsx | 5 +- packages/ui/src/components/Slider/Slider.tsx | 94 ++++++++----------- 3 files changed, 45 insertions(+), 62 deletions(-) diff --git a/packages/ui/src/components/Slider/Slider.module.css b/packages/ui/src/components/Slider/Slider.module.css index a84be7a306..69fc8784f9 100644 --- a/packages/ui/src/components/Slider/Slider.module.css +++ b/packages/ui/src/components/Slider/Slider.module.css @@ -90,7 +90,7 @@ 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%); @@ -118,15 +118,15 @@ opacity: 0.6; cursor: not-allowed; - .bui-SliderTrack { + & .bui-SliderTrack { background: var(--bui-bg-neutral-2); } - .bui-SliderTrackFill { + & .bui-SliderTrackFill { background: var(--bui-bg-neutral-4); } - .bui-SliderThumb { + & .bui-SliderThumb { cursor: not-allowed; background: var(--bui-bg-neutral-4); border-color: var(--bui-border-neutral); diff --git a/packages/ui/src/components/Slider/Slider.stories.tsx b/packages/ui/src/components/Slider/Slider.stories.tsx index b03f45412c..7ac48b877b 100644 --- a/packages/ui/src/components/Slider/Slider.stories.tsx +++ b/packages/ui/src/components/Slider/Slider.stories.tsx @@ -110,8 +110,9 @@ export const Percentage = meta.story({ args: { label: 'Completion', minValue: 0, - maxValue: 100, - defaultValue: 65, + maxValue: 1, + step: 0.01, + defaultValue: 0.65, formatOptions: { style: 'percent', minimumFractionDigits: 0, diff --git a/packages/ui/src/components/Slider/Slider.tsx b/packages/ui/src/components/Slider/Slider.tsx index 3bfb407504..f83f93b51a 100644 --- a/packages/ui/src/components/Slider/Slider.tsx +++ b/packages/ui/src/components/Slider/Slider.tsx @@ -27,7 +27,6 @@ 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( props: SliderProps, @@ -41,11 +40,10 @@ function SliderImpl( secondaryLabel, defaultValue, value, + isRequired, ...restProps } = props; - const isRequired = (props as any).isRequired; - useEffect(() => { if (!label && !ariaLabel && !ariaLabelledBy) { console.warn( @@ -63,12 +61,9 @@ function SliderImpl( 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 ( ( ref={ref} > {label && ( -
+
- + {({ state }) => state.values .map((_, i) => state.getThumbValueLabel(i)) @@ -95,51 +88,40 @@ function SliderImpl(
)} - - {({ state }) => ( - <> - {(() => { - const numThumbs = state.values.length; + + {({ 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}%` }; - } + // 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 ( -
- ); - })()} - - {isRange && ( - - )} - - )} + return ( + <> +
+ + {numThumbs > 1 && ( + + )} + + ); + }} @@ -149,6 +131,6 @@ function SliderImpl( /** @public */ export const Slider = forwardRef(SliderImpl) as ( props: SliderProps & { ref?: React.ForwardedRef }, -) => ReturnType; +) => JSX.Element; -(Slider as any).displayName = 'Slider'; +Slider.displayName = 'Slider'; From 62cf259d801116da0b672e6001a15c346b1a4b68 Mon Sep 17 00:00:00 2001 From: AmbrishRamachandiran Date: Tue, 10 Mar 2026 11:59:38 +0530 Subject: [PATCH 17/24] Fix review comments added single slider Signed-off-by: AmbrishRamachandiran --- packages/ui/src/components/Slider/Slider.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/components/Slider/Slider.tsx b/packages/ui/src/components/Slider/Slider.tsx index f83f93b51a..c2e7dd9455 100644 --- a/packages/ui/src/components/Slider/Slider.tsx +++ b/packages/ui/src/components/Slider/Slider.tsx @@ -40,7 +40,6 @@ function SliderImpl( secondaryLabel, defaultValue, value, - isRequired, ...restProps } = props; @@ -59,7 +58,8 @@ function SliderImpl( } = useDefinition(SliderDefinition, restProps); const { classes, className } = ownProps; - const secondaryLabelText = secondaryLabel || (isRequired ? 'Required' : null); + const secondaryLabelText = + secondaryLabel || (definitionRest.isRequired ? 'Required' : null); return ( ( } /** @public */ -export const Slider = forwardRef(SliderImpl) as ( +export const Slider = forwardRef(SliderImpl) as (( props: SliderProps & { ref?: React.ForwardedRef }, -) => JSX.Element; +) => JSX.Element) & { displayName: string }; Slider.displayName = 'Slider'; From 41ec1e20e2ad3fc098b6099904afdd8dddf128ef Mon Sep 17 00:00:00 2001 From: AmbrishRamachandiran Date: Tue, 10 Mar 2026 12:41:34 +0530 Subject: [PATCH 18/24] Fix review comments added single slider Signed-off-by: AmbrishRamachandiran --- packages/ui/src/components/Slider/Slider.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/Slider/Slider.tsx b/packages/ui/src/components/Slider/Slider.tsx index c2e7dd9455..d3156ee3f5 100644 --- a/packages/ui/src/components/Slider/Slider.tsx +++ b/packages/ui/src/components/Slider/Slider.tsx @@ -40,6 +40,7 @@ function SliderImpl( secondaryLabel, defaultValue, value, + isRequired, ...restProps } = props; @@ -58,8 +59,7 @@ function SliderImpl( } = useDefinition(SliderDefinition, restProps); const { classes, className } = ownProps; - const secondaryLabelText = - secondaryLabel || (definitionRest.isRequired ? 'Required' : null); + const secondaryLabelText = secondaryLabel || (isRequired ? 'Required' : null); return ( ( aria-labelledby={ariaLabelledBy} defaultValue={defaultValue} value={value} + isRequired={isRequired} {...definitionRest} ref={ref} > From 37b7ca137c9334630fc7cc9b2b72915dcac7d13c Mon Sep 17 00:00:00 2001 From: AmbrishRamachandiran Date: Tue, 10 Mar 2026 13:03:06 +0530 Subject: [PATCH 19/24] Fix review comments added single slider Signed-off-by: AmbrishRamachandiran --- packages/ui/src/components/Slider/Slider.tsx | 30 ++++++------------- .../ui/src/components/Slider/definition.ts | 4 +++ packages/ui/src/components/Slider/types.ts | 11 ++++++- 3 files changed, 23 insertions(+), 22 deletions(-) diff --git a/packages/ui/src/components/Slider/Slider.tsx b/packages/ui/src/components/Slider/Slider.tsx index d3156ee3f5..a417dfecb0 100644 --- a/packages/ui/src/components/Slider/Slider.tsx +++ b/packages/ui/src/components/Slider/Slider.tsx @@ -32,17 +32,14 @@ function SliderImpl( props: SliderProps, ref: React.ForwardedRef, ) { - const { - label, - 'aria-label': ariaLabel, - 'aria-labelledby': ariaLabelledBy, - description, - secondaryLabel, - defaultValue, - value, - isRequired, - ...restProps - } = props; + const { ownProps, restProps } = useDefinition(SliderDefinition, props); + const { classes, className, label, secondaryLabel, description, isRequired } = + ownProps; + + const ariaLabel = restProps['aria-label']; + const ariaLabelledBy = restProps['aria-labelledby']; + const defaultValue = restProps.defaultValue; + const value = restProps.value; useEffect(() => { if (!label && !ariaLabel && !ariaLabelledBy) { @@ -52,25 +49,16 @@ function SliderImpl( } }, [label, ariaLabel, ariaLabelledBy]); - const { - ownProps, - restProps: definitionRest, - dataAttributes, - } = useDefinition(SliderDefinition, restProps); - const { classes, className } = ownProps; - const secondaryLabelText = secondaryLabel || (isRequired ? 'Required' : null); return ( {label && ( diff --git a/packages/ui/src/components/Slider/definition.ts b/packages/ui/src/components/Slider/definition.ts index ab460c93ba..fdfd19939f 100644 --- a/packages/ui/src/components/Slider/definition.ts +++ b/packages/ui/src/components/Slider/definition.ts @@ -34,5 +34,9 @@ export const SliderDefinition = defineComponent()({ }, propDefs: { className: {}, + label: {}, + secondaryLabel: {}, + description: {}, + isRequired: {}, }, }); diff --git a/packages/ui/src/components/Slider/types.ts b/packages/ui/src/components/Slider/types.ts index ce58fef748..55ab3e1448 100644 --- a/packages/ui/src/components/Slider/types.ts +++ b/packages/ui/src/components/Slider/types.ts @@ -20,6 +20,10 @@ import type { FieldLabelProps } from '../FieldLabel/types'; /** @public */ export interface SliderOwnProps { className?: string; + label?: FieldLabelProps['label']; + secondaryLabel?: FieldLabelProps['secondaryLabel']; + description?: FieldLabelProps['description']; + isRequired?: boolean; } /** @public */ @@ -34,4 +38,9 @@ export interface SliderProps | 'onChange' | 'slot' | 'style' - > {} + | 'label' + | 'secondaryLabel' + | 'description' + | 'isRequired' + >, + SliderOwnProps {} From 993e272418c9510cf736c15b3c225fc4c0562574 Mon Sep 17 00:00:00 2001 From: AmbrishRamachandiran Date: Tue, 10 Mar 2026 14:00:22 +0530 Subject: [PATCH 20/24] Fix review comments added single slider Signed-off-by: AmbrishRamachandiran --- packages/ui/src/components/Slider/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/Slider/types.ts b/packages/ui/src/components/Slider/types.ts index 55ab3e1448..d0d0667e42 100644 --- a/packages/ui/src/components/Slider/types.ts +++ b/packages/ui/src/components/Slider/types.ts @@ -28,7 +28,7 @@ export interface SliderOwnProps { /** @public */ export interface SliderProps - extends Omit, 'children'>, + extends Omit, 'children' | 'className'>, Omit< FieldLabelProps, | 'htmlFor' From 2e00fb7e701f0d0ce7f7428882fe7ff425f60bb8 Mon Sep 17 00:00:00 2001 From: AmbrishRamachandiran Date: Tue, 10 Mar 2026 20:37:15 +0530 Subject: [PATCH 21/24] Fix review comments added single slider Signed-off-by: AmbrishRamachandiran --- packages/ui/report.api.md | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index 0c9559e74a..3640047ef4 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -2239,14 +2239,14 @@ export interface SkeletonProps extends Omit, '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: ( +export const Slider: (( props: SliderProps & { ref?: React.ForwardedRef; }, -) => ReturnType; +) => JSX.Element) & { + displayName: string; +}; // @public export const SliderDefinition: { @@ -2263,6 +2263,10 @@ export const SliderDefinition: { }; readonly propDefs: { readonly className: {}; + readonly label: {}; + readonly secondaryLabel: {}; + readonly description: {}; + readonly isRequired: {}; }; }; @@ -2270,11 +2274,19 @@ export const SliderDefinition: { 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 - extends Omit, 'children'>, + extends Omit, 'children' | 'className'>, Omit< FieldLabelProps, | 'htmlFor' @@ -2284,7 +2296,12 @@ export interface SliderProps | 'onChange' | 'slot' | 'style' - > {} + | 'label' + | 'secondaryLabel' + | 'description' + | 'isRequired' + >, + SliderOwnProps {} // @public (undocumented) export type SortDescriptor = SortDescriptor_2; From 4776d564ceb30c2541a8a6322315cf684373ea7d Mon Sep 17 00:00:00 2001 From: AmbrishRamachandiran Date: Wed, 11 Mar 2026 21:47:18 +0530 Subject: [PATCH 22/24] Fix review comments Signed-off-by: AmbrishRamachandiran --- .../src/components/FieldLabel/FieldLabel.tsx | 15 +++++++++++--- .../src/components/FieldLabel/definition.ts | 1 + .../ui/src/components/FieldLabel/types.ts | 5 +++++ packages/ui/src/components/Slider/Slider.tsx | 20 +++++++++---------- packages/ui/src/components/Slider/types.ts | 14 ------------- 5 files changed, 27 insertions(+), 28 deletions(-) diff --git a/packages/ui/src/components/FieldLabel/FieldLabel.tsx b/packages/ui/src/components/FieldLabel/FieldLabel.tsx index 924ac29464..7656cacc5e 100644 --- a/packages/ui/src/components/FieldLabel/FieldLabel.tsx +++ b/packages/ui/src/components/FieldLabel/FieldLabel.tsx @@ -23,8 +23,15 @@ import { FieldLabelDefinition } from './definition'; export const FieldLabel = forwardRef( (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( )} {description && ( -
{description}
+
+ {description} +
)}
); diff --git a/packages/ui/src/components/FieldLabel/definition.ts b/packages/ui/src/components/FieldLabel/definition.ts index dc55c4f2af..399ea9e54d 100644 --- a/packages/ui/src/components/FieldLabel/definition.ts +++ b/packages/ui/src/components/FieldLabel/definition.ts @@ -36,6 +36,7 @@ export const FieldLabelDefinition = defineComponent()({ description: {}, htmlFor: {}, id: {}, + descriptionId: {}, className: {}, }, }); diff --git a/packages/ui/src/components/FieldLabel/types.ts b/packages/ui/src/components/FieldLabel/types.ts index f1d2857e06..ca18c6c8c1 100644 --- a/packages/ui/src/components/FieldLabel/types.ts +++ b/packages/ui/src/components/FieldLabel/types.ts @@ -41,6 +41,11 @@ export type FieldLabelOwnProps = { */ id?: string; + /** + * The id to apply to the description element for aria-describedby + */ + descriptionId?: string; + className?: string; }; diff --git a/packages/ui/src/components/Slider/Slider.tsx b/packages/ui/src/components/Slider/Slider.tsx index a417dfecb0..9aa2d73beb 100644 --- a/packages/ui/src/components/Slider/Slider.tsx +++ b/packages/ui/src/components/Slider/Slider.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { forwardRef, useEffect } from 'react'; +import { forwardRef, useEffect, useId } from 'react'; import { Slider as AriaSlider, SliderTrack, @@ -36,37 +36,35 @@ function SliderImpl( const { classes, className, label, secondaryLabel, description, isRequired } = ownProps; - const ariaLabel = restProps['aria-label']; - const ariaLabelledBy = restProps['aria-labelledby']; - const defaultValue = restProps.defaultValue; - const value = restProps.value; + const labelId = useId(); + const descriptionId = useId(); useEffect(() => { - if (!label && !ariaLabel && !ariaLabelledBy) { + if (!label && !restProps['aria-label'] && !restProps['aria-labelledby']) { console.warn( 'Slider requires either a visible label, aria-label, or aria-labelledby for accessibility', ); } - }, [label, ariaLabel, ariaLabelledBy]); + }, [label, restProps]); const secondaryLabelText = secondaryLabel || (isRequired ? 'Required' : null); return ( {label && (
{({ state }) => diff --git a/packages/ui/src/components/Slider/types.ts b/packages/ui/src/components/Slider/types.ts index d0d0667e42..b254519fce 100644 --- a/packages/ui/src/components/Slider/types.ts +++ b/packages/ui/src/components/Slider/types.ts @@ -29,18 +29,4 @@ export interface SliderOwnProps { /** @public */ export interface SliderProps extends Omit, 'children' | 'className'>, - Omit< - FieldLabelProps, - | 'htmlFor' - | 'id' - | 'className' - | 'defaultValue' - | 'onChange' - | 'slot' - | 'style' - | 'label' - | 'secondaryLabel' - | 'description' - | 'isRequired' - >, SliderOwnProps {} From 45bedc5eebfbad432ce43ff83c698abc942362ad Mon Sep 17 00:00:00 2001 From: AmbrishRamachandiran Date: Tue, 17 Mar 2026 13:32:21 +0530 Subject: [PATCH 23/24] Resolve merge conflicts Signed-off-by: AmbrishRamachandiran --- docs-ui/src/utils/data.ts | 16 ++++++++++++++-- packages/ui/src/definitions.ts | 6 +++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/docs-ui/src/utils/data.ts b/docs-ui/src/utils/data.ts index 01b1e1c494..8e101ffd78 100644 --- a/docs-ui/src/utils/data.ts +++ b/docs-ui/src/utils/data.ts @@ -86,8 +86,8 @@ export const components: Page[] = [ slug: 'radio-group', }, { - title: 'Slider', - slug: 'slider', + title: 'SearchAutocomplete', + slug: 'search-autocomplete', }, { title: 'SearchField', @@ -101,6 +101,18 @@ export const components: Page[] = [ title: 'Skeleton', slug: 'skeleton', }, + { + title: 'Slider', + slug: 'slider', + }, + { + title: 'Select', + slug: 'select', + }, + { + title: 'Skeleton', + slug: 'skeleton', + }, { title: 'Switch', slug: 'switch', diff --git a/packages/ui/src/definitions.ts b/packages/ui/src/definitions.ts index 96bd311caf..bc0686a3b8 100644 --- a/packages/ui/src/definitions.ts +++ b/packages/ui/src/definitions.ts @@ -52,10 +52,14 @@ 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 { SliderDefinition } from './components/Slider/definition'; +export { + SearchAutocompleteDefinition, + SearchAutocompleteItemDefinition, +} from './components/SearchAutocomplete/definition'; 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'; From da68fd826b451874a2869dbe81c54c53853d5ef2 Mon Sep 17 00:00:00 2001 From: AmbrishRamachandiran Date: Tue, 17 Mar 2026 13:56:33 +0530 Subject: [PATCH 24/24] Resolve merge conflicts Signed-off-by: AmbrishRamachandiran --- docs-ui/src/utils/data.ts | 4 ---- packages/ui/report.api.md | 16 ++-------------- packages/ui/src/definitions.ts | 4 ---- 3 files changed, 2 insertions(+), 22 deletions(-) diff --git a/docs-ui/src/utils/data.ts b/docs-ui/src/utils/data.ts index 8e101ffd78..6ab3ff5dec 100644 --- a/docs-ui/src/utils/data.ts +++ b/docs-ui/src/utils/data.ts @@ -85,10 +85,6 @@ export const components: Page[] = [ title: 'RadioGroup', slug: 'radio-group', }, - { - title: 'SearchAutocomplete', - slug: 'search-autocomplete', - }, { title: 'SearchField', slug: 'search-field', diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index 3640047ef4..5f9535ecf4 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -1162,6 +1162,7 @@ export const FieldLabelDefinition: { readonly description: {}; readonly htmlFor: {}; readonly id: {}; + readonly descriptionId: {}; readonly className: {}; }; }; @@ -1173,6 +1174,7 @@ export type FieldLabelOwnProps = { description?: string | null; htmlFor?: string; id?: string; + descriptionId?: string; className?: string; }; @@ -2287,20 +2289,6 @@ export interface SliderOwnProps { // @public (undocumented) export interface SliderProps extends Omit, 'children' | 'className'>, - Omit< - FieldLabelProps, - | 'htmlFor' - | 'id' - | 'className' - | 'defaultValue' - | 'onChange' - | 'slot' - | 'style' - | 'label' - | 'secondaryLabel' - | 'description' - | 'isRequired' - >, SliderOwnProps {} // @public (undocumented) diff --git a/packages/ui/src/definitions.ts b/packages/ui/src/definitions.ts index bc0686a3b8..bf43021dcd 100644 --- a/packages/ui/src/definitions.ts +++ b/packages/ui/src/definitions.ts @@ -52,10 +52,6 @@ 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 { - SearchAutocompleteDefinition, - SearchAutocompleteItemDefinition, -} from './components/SearchAutocomplete/definition'; export { SearchFieldDefinition } from './components/SearchField/definition'; export { SelectDefinition } from './components/Select/definition'; export { SkeletonDefinition } from './components/Skeleton/definition';