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';