diff --git a/apps/vr-tests-react-components/src/stories/RangeSlider/RangeSlider.stories.tsx b/apps/vr-tests-react-components/src/stories/RangeSlider/RangeSlider.stories.tsx new file mode 100644 index 00000000000000..2fe036937430c4 --- /dev/null +++ b/apps/vr-tests-react-components/src/stories/RangeSlider/RangeSlider.stories.tsx @@ -0,0 +1,66 @@ +import * as React from 'react'; +import type { Meta } from '@storybook/react-webpack5'; +import { Steps, type StoryParameters } from 'storywright'; +import { RangeSlider } from '@fluentui/react-slider'; +import { getStoryVariant, RTL, TestWrapperDecorator } from '../../utilities'; + +export default { + title: 'RangeSlider Converged', + decorators: [TestWrapperDecorator], + parameters: { + storyWright: { steps: new Steps().snapshot('default', { cropTo: '.testWrapper' }).end() }, + } satisfies StoryParameters, +} satisfies Meta; + +export const Horizontal0 = () => ; +Horizontal0.storyName = 'Horizontal - 0%'; + +export const Horizontal0RTL = getStoryVariant(Horizontal0, RTL); + +export const HorizontalMiddle = () => ; +HorizontalMiddle.storyName = 'Horizontal - Middle'; + +export const HorizontalMiddleRTL = getStoryVariant(HorizontalMiddle, RTL); + +export const Horizontal100 = () => ; +Horizontal100.storyName = 'Horizontal - 100%'; + +export const Horizontal100RTL = getStoryVariant(Horizontal100, RTL); + +export const HorizontalFullRange = () => ; +HorizontalFullRange.storyName = 'Horizontal - Full Range'; + +export const Vertical0 = () => ; +Vertical0.storyName = 'Vertical - 0%'; + +export const Vertical0RTL = getStoryVariant(Vertical0, RTL); + +export const VerticalMiddle = () => ; +VerticalMiddle.storyName = 'Vertical - Middle'; + +export const VerticalMiddleRTL = getStoryVariant(VerticalMiddle, RTL); + +export const Vertical100 = () => ; +Vertical100.storyName = 'Vertical - 100%'; + +export const Vertical100RTL = getStoryVariant(Vertical100, RTL); + +export const SizeMedium = () => ; +SizeMedium.storyName = 'Size - medium'; + +export const SizeSmall = () => ; +SizeSmall.storyName = 'Size - small'; + +export const SizeSmallVertical = () => ; +SizeSmallVertical.storyName = 'Size - small vertical'; + +export const Step = () => ; +Step.storyName = 'Step'; + +export const StepVertical = () => ( + +); +StepVertical.storyName = 'Step - vertical'; + +export const MinMax = () => ; +MinMax.storyName = 'Custom min/max'; diff --git a/apps/vr-tests-react-components/src/stories/RangeSlider/RangeSliderInteractions.stories.tsx b/apps/vr-tests-react-components/src/stories/RangeSlider/RangeSliderInteractions.stories.tsx new file mode 100644 index 00000000000000..ca4165598de9fe --- /dev/null +++ b/apps/vr-tests-react-components/src/stories/RangeSlider/RangeSliderInteractions.stories.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import type { Meta } from '@storybook/react-webpack5'; +import { Steps, type StoryParameters } from 'storywright'; +import { RangeSlider } from '@fluentui/react-slider'; +import { DARK_MODE, getStoryVariant, HIGH_CONTRAST, RTL, TestWrapperDecorator } from '../../utilities'; + +export default { + title: 'RangeSlider Converged', + decorators: [TestWrapperDecorator], + parameters: { + storyWright: { + steps: new Steps() + .snapshot('default', { cropTo: '.testWrapper' }) + .hover('.test-class') + .snapshot('hover', { cropTo: '.testWrapper' }) + .mouseDown('.test-class') + .snapshot('pressed', { cropTo: '.testWrapper' }) + .mouseUp('.test-class') + .end(), + }, + } satisfies StoryParameters, +} satisfies Meta; + +export const Root = () => ; + +export const RootRTL = getStoryVariant(Root, RTL); + +export const RootHighContrast = getStoryVariant(Root, HIGH_CONTRAST); + +export const RootDarkMode = getStoryVariant(Root, DARK_MODE); + +export const Vertical = () => ; + +export const VerticalRTL = getStoryVariant(Vertical, RTL); + +export const Disabled = () => ; + +export const DisabledHighContrast = getStoryVariant(Disabled, HIGH_CONTRAST); + +export const DisabledDarkMode = getStoryVariant(Disabled, DARK_MODE); + +export const DisabledVertical = () => ( + +); diff --git a/packages/react-components/react-slider/library/etc/react-slider.api.md b/packages/react-components/react-slider/library/etc/react-slider.api.md index d0c3a2fb93abd9..77eacd99925f9f 100644 --- a/packages/react-components/react-slider/library/etc/react-slider.api.md +++ b/packages/react-components/react-slider/library/etc/react-slider.api.md @@ -6,6 +6,8 @@ import type { ComponentProps } from '@fluentui/react-utilities'; import type { ComponentState } from '@fluentui/react-utilities'; +import type { EventData } from '@fluentui/react-utilities'; +import type { EventHandler } from '@fluentui/react-utilities'; import type { ForwardRefComponent } from '@fluentui/react-utilities'; import type { JSXElement } from '@fluentui/react-utilities'; import * as React_2 from 'react'; @@ -18,11 +20,62 @@ export const RangeSlider: ForwardRefComponent; // @public (undocumented) export const rangeSliderClassNames: SlotClassNames; -// @public -export type RangeSliderProps = ComponentProps & {}; +// @public (undocumented) +export const rangeSliderCSSVars: { + rangeSliderDirectionVar: string; + rangeSliderInnerThumbRadiusVar: string; + rangeSliderLowerProgressVar: string; + rangeSliderUpperProgressVar: string; + rangeSliderProgressColorVar: string; + rangeSliderRailSizeVar: string; + rangeSliderRailColorVar: string; + rangeSliderStepsPercentVar: string; + rangeSliderThumbColorVar: string; + rangeSliderThumbSizeVar: string; +}; -// @public -export type RangeSliderState = ComponentState; +// @public (undocumented) +export type RangeSliderOnChangeData = EventData<'change', React_2.ChangeEvent> & { + value: RangeSliderValue; +}; + +// @public (undocumented) +export type RangeSliderProps = Omit, 'startInput' | 'endInput'>, 'defaultValue' | 'onChange' | 'size' | 'value'> & { + defaultValue?: RangeSliderValue; + disabled?: boolean; + max?: number; + min?: number; + size?: 'small' | 'medium'; + step?: number; + value?: RangeSliderValue; + vertical?: boolean; + onChange?: EventHandler; +}; + +// @public (undocumented) +export type RangeSliderSlots = { + root: NonNullable>; + rail: NonNullable>; + startThumb: NonNullable>; + endThumb: NonNullable>; + startInput: NonNullable> & { + orient?: 'horizontal' | 'vertical'; + }; + endInput: NonNullable> & { + orient?: 'horizontal' | 'vertical'; + }; +}; + +// @public (undocumented) +export type RangeSliderState = ComponentState & Pick & { + value: RangeSliderValue; +}; + +// @public (undocumented) +export type RangeSliderValue = { + start: number; + end: number; +}; // @public export const renderRangeSlider_unstable: (state: RangeSliderState) => JSXElement; @@ -83,7 +136,10 @@ export type SliderState = ComponentState & Pick) => RangeSliderState; -// @public +// @public (undocumented) +export const useRangeSliderState_unstable: (state: RangeSliderState, props: RangeSliderProps) => RangeSliderState; + +// @public (undocumented) export const useRangeSliderStyles_unstable: (state: RangeSliderState) => RangeSliderState; // @public (undocumented) diff --git a/packages/react-components/react-slider/library/src/RangeSlider.ts b/packages/react-components/react-slider/library/src/RangeSlider.ts index 3a9d0c6dacbc42..d0acaed2461c1f 100644 --- a/packages/react-components/react-slider/library/src/RangeSlider.ts +++ b/packages/react-components/react-slider/library/src/RangeSlider.ts @@ -1,8 +1,16 @@ export { rangeSliderClassNames, + rangeSliderCSSVars, RangeSlider, renderRangeSlider_unstable, useRangeSlider_unstable, + useRangeSliderState_unstable, useRangeSliderStyles_unstable, } from './components/RangeSlider/index'; -export type { RangeSliderProps, RangeSliderState } from './components/RangeSlider/index'; +export type { + RangeSliderOnChangeData, + RangeSliderProps, + RangeSliderSlots, + RangeSliderState, + RangeSliderValue, +} from './components/RangeSlider/index'; diff --git a/packages/react-components/react-slider/library/src/components/RangeSlider/RangeSlider.test.tsx b/packages/react-components/react-slider/library/src/components/RangeSlider/RangeSlider.test.tsx index f04e7446460349..7b7dc2a44e293b 100644 --- a/packages/react-components/react-slider/library/src/components/RangeSlider/RangeSlider.test.tsx +++ b/packages/react-components/react-slider/library/src/components/RangeSlider/RangeSlider.test.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; -import { render } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; +import { resetIdsForTests } from '@fluentui/react-utilities'; import { isConformant } from '../../testing/isConformant'; import { RangeSlider } from './RangeSlider'; @@ -7,12 +8,173 @@ describe('RangeSlider', () => { isConformant({ Component: RangeSlider, displayName: 'RangeSlider', + // RangeSlider has two primary input slots (startInput/endInput), so the standard + // primary-slot conformance test does not apply. The ref goes to the root
. + disabledTests: ['primary-slot-gets-native-props'], }); - // TODO add more tests here, and create visual regression tests in /apps/vr-tests + afterEach(() => { + resetIdsForTests(); + }); + + // Snapshot tests + it('renders horizontal RangeSlider correctly', () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it('renders vertical RangeSlider correctly', () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it('renders disabled RangeSlider correctly', () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + // Unit tests + it('renders two slider inputs', () => { + render(); + const sliders = screen.getAllByRole('slider'); + expect(sliders).toHaveLength(2); + }); + + it('applies the defaultValue prop', () => { + render(); + const sliders = screen.getAllByRole('slider'); + expect(sliders[0].getAttribute('value')).toEqual('20'); + expect(sliders[1].getAttribute('value')).toEqual('80'); + }); + + it('applies the value prop', () => { + render(); + const sliders = screen.getAllByRole('slider'); + expect(sliders[0].getAttribute('value')).toEqual('30'); + expect(sliders[1].getAttribute('value')).toEqual('70'); + }); + + it('clamps values when start is below min', () => { + render(); + const sliders = screen.getAllByRole('slider'); + expect(sliders[0].getAttribute('value')).toEqual('0'); + }); + + it('clamps values when end is above max', () => { + render(); + const sliders = screen.getAllByRole('slider'); + expect(sliders[1].getAttribute('value')).toEqual('100'); + }); + + it('applies the disabled prop', () => { + render(); + const sliders = screen.getAllByRole('slider'); + expect(sliders[0].getAttribute('disabled')).toBeDefined(); + expect(sliders[1].getAttribute('disabled')).toBeDefined(); + }); + + it('applies the min prop', () => { + render(); + const sliders = screen.getAllByRole('slider'); + expect(sliders[0].getAttribute('min')).toEqual('10'); + }); + + it('applies the max prop', () => { + render(); + const sliders = screen.getAllByRole('slider'); + expect(sliders[1].getAttribute('max')).toEqual('90'); + }); + + it('applies the step prop', () => { + render(); + const sliders = screen.getAllByRole('slider'); + expect(sliders[0].getAttribute('step')).toEqual('5'); + expect(sliders[1].getAttribute('step')).toEqual('5'); + }); + + it('constrains startInput max to the upper value', () => { + render(); + const sliders = screen.getAllByRole('slider'); + expect(sliders[0].getAttribute('max')).toEqual('60'); + }); + + it('constrains endInput min to the lower value', () => { + render(); + const sliders = screen.getAllByRole('slider'); + expect(sliders[1].getAttribute('min')).toEqual('20'); + }); + + it('orders values correctly when start > end', () => { + render(); + const sliders = screen.getAllByRole('slider'); + // Values should be sorted: lower=20, upper=80 + expect(sliders[0].getAttribute('value')).toEqual('20'); + expect(sliders[1].getAttribute('value')).toEqual('80'); + }); + + // Focus tests + it('applies focus to the start input', () => { + render(); + const sliders = screen.getAllByRole('slider'); + sliders[0].focus(); + expect(document.activeElement).toEqual(sliders[0]); + }); + + it('applies focus to the end input', () => { + render(); + const sliders = screen.getAllByRole('slider'); + sliders[1].focus(); + expect(document.activeElement).toEqual(sliders[1]); + }); + + it('does not allow focus on disabled RangeSlider', () => { + render(); + const sliders = screen.getAllByRole('slider'); + expect(document.activeElement).toEqual(document.body); + sliders[0].focus(); + expect(document.activeElement).toEqual(document.body); + }); + + // Accessibility tests + it('allows aria-label to be set on each input via slots', () => { + render( + , + ); + const sliders = screen.getAllByRole('slider'); + expect(sliders[0].getAttribute('aria-label')).toEqual('Minimum value'); + expect(sliders[1].getAttribute('aria-label')).toEqual('Maximum value'); + }); + + it('applies aria-labelledby to both inputs', () => { + render(); + const sliders = screen.getAllByRole('slider'); + expect(sliders[0].getAttribute('aria-labelledby')).toEqual('test-label'); + expect(sliders[1].getAttribute('aria-labelledby')).toEqual('test-label'); + }); + + it('applies aria-valuetext to both inputs', () => { + render(); + const sliders = screen.getAllByRole('slider'); + expect(sliders[0].getAttribute('aria-valuetext')).toEqual('test-value'); + expect(sliders[1].getAttribute('aria-valuetext')).toEqual('test-value'); + }); + + it('provides each input with type range', () => { + render(); + const sliders = screen.getAllByRole('slider'); + expect(sliders[0].getAttribute('type')).toEqual('range'); + expect(sliders[1].getAttribute('type')).toEqual('range'); + }); - it('renders a default state', () => { - const result = render(Default RangeSlider); - expect(result.container).toMatchSnapshot(); + it('generates unique ids for each input', () => { + render(); + const sliders = screen.getAllByRole('slider'); + expect(sliders[0].id).not.toEqual(sliders[1].id); + expect(sliders[0].id).toContain('rangeslider-start-'); + expect(sliders[1].id).toContain('rangeslider-end-'); }); }); diff --git a/packages/react-components/react-slider/library/src/components/RangeSlider/RangeSlider.tsx b/packages/react-components/react-slider/library/src/components/RangeSlider/RangeSlider.tsx index d7b37179eb6679..c2eb093b9843d0 100644 --- a/packages/react-components/react-slider/library/src/components/RangeSlider/RangeSlider.tsx +++ b/packages/react-components/react-slider/library/src/components/RangeSlider/RangeSlider.tsx @@ -1,3 +1,5 @@ +'use client'; + import * as React from 'react'; import type { ForwardRefComponent } from '@fluentui/react-utilities'; import { useRangeSlider_unstable } from './useRangeSlider'; @@ -6,7 +8,7 @@ import { useRangeSliderStyles_unstable } from './useRangeSliderStyles.styles'; import type { RangeSliderProps } from './RangeSlider.types'; /** - * RangeSlider component - TODO: add more docs + * RangeSlider allows selecting a lower and upper value across the same rail. */ export const RangeSlider: ForwardRefComponent = React.forwardRef((props, ref) => { const state = useRangeSlider_unstable(props, ref); diff --git a/packages/react-components/react-slider/library/src/components/RangeSlider/RangeSlider.types.ts b/packages/react-components/react-slider/library/src/components/RangeSlider/RangeSlider.types.ts index ee80c148a33093..5a4d967b92e66d 100644 --- a/packages/react-components/react-slider/library/src/components/RangeSlider/RangeSlider.types.ts +++ b/packages/react-components/react-slider/library/src/components/RangeSlider/RangeSlider.types.ts @@ -1,17 +1,126 @@ -import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; +import * as React from 'react'; +import type { ComponentProps, ComponentState, EventData, EventHandler, Slot } from '@fluentui/react-utilities'; + +export type RangeSliderValue = { + /** + * Lower value for the range. + */ + start: number; + + /** + * Upper value for the range. + */ + end: number; +}; + +export type RangeSliderOnChangeData = EventData<'change', React.ChangeEvent> & { + value: RangeSliderValue; +}; export type RangeSliderSlots = { - root: Slot<'div'>; + /** + * The root of the RangeSlider. + * The root slot receives the `className` and `style` specified directly on the ``. + */ + root: NonNullable>; + + /** + * The RangeSlider's base. It is used to display the currently selected range. + */ + rail: NonNullable>; + + /** + * Visual-only thumb that represents the minimum value. Focus and interaction come from the nested input. + */ + startThumb: NonNullable>; + + /** + * Visual-only thumb that represents the maximum value. Focus and interaction come from the nested input. + */ + endThumb: NonNullable>; + + /** + * Visually hidden `` that owns the lower value for accessibility and forms. + */ + startInput: NonNullable> & { + /** + * Orient is a non standard attribute that allows for vertical orientation in Firefox. It is set internally + * when `vertical` is set to true. + * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/range#non_standard_attributes + * Webkit/Chromium support for vertical inputs is provided via -webkit-appearance css property + */ + orient?: 'horizontal' | 'vertical'; + }; + + /** + * Visually hidden `` that owns the upper value for accessibility and forms. + */ + endInput: NonNullable> & { + /** + * Orient is a non standard attribute that allows for vertical orientation in Firefox. It is set internally + * when `vertical` is set to true. + * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/range#non_standard_attributes + * Webkit/Chromium support for vertical inputs is provided via -webkit-appearance css property + */ + orient?: 'horizontal' | 'vertical'; + }; +}; + +export type RangeSliderProps = Omit< + ComponentProps, 'startInput' | 'endInput'>, + 'defaultValue' | 'onChange' | 'size' | 'value' +> & { + /** + * The starting value for an uncontrolled RangeSlider. + */ + defaultValue?: RangeSliderValue; + + /** + * Whether the RangeSlider is disabled. + */ + disabled?: boolean; + + /** + * Maximum slider value. + * @default 100 + */ + max?: number; + + /** + * Minimum slider value. + * @default 0 + */ + min?: number; + + /** + * Size of the slider. + * @default 'medium' + */ + size?: 'small' | 'medium'; + + /** + * Step amount the slider will change by when moved. + * @default 1 + */ + step?: number; + + /** + * Controlled value for the RangeSlider. + */ + value?: RangeSliderValue; + + /** + * Render the RangeSlider vertically with the smallest value at the bottom. + */ + vertical?: boolean; + + /** + * Fires when the slider values change. + */ + onChange?: EventHandler; }; -/** - * RangeSlider Props - */ -export type RangeSliderProps = ComponentProps & {}; - -/** - * State used in rendering RangeSlider - */ -export type RangeSliderState = ComponentState; -// TODO: Remove semicolon from previous line, uncomment next line, and provide union of props to pick from RangeSliderProps. -// & Required> +export type RangeSliderState = ComponentState & + Pick & { + value: RangeSliderValue; + }; diff --git a/packages/react-components/react-slider/library/src/components/RangeSlider/__snapshots__/RangeSlider.test.tsx.snap b/packages/react-components/react-slider/library/src/components/RangeSlider/__snapshots__/RangeSlider.test.tsx.snap index 914725d48ad1be..4e9a61ebda52fc 100644 --- a/packages/react-components/react-slider/library/src/components/RangeSlider/__snapshots__/RangeSlider.test.tsx.snap +++ b/packages/react-components/react-slider/library/src/components/RangeSlider/__snapshots__/RangeSlider.test.tsx.snap @@ -1,11 +1,128 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`RangeSlider renders a default state 1`] = ` +exports[`RangeSlider renders disabled RangeSlider correctly 1`] = `
- Default RangeSlider +
+ + +
+
+`; + +exports[`RangeSlider renders horizontal RangeSlider correctly 1`] = ` +
+
+
+ + +
+
+`; + +exports[`RangeSlider renders vertical RangeSlider correctly 1`] = ` +
+
+
+ +
`; diff --git a/packages/react-components/react-slider/library/src/components/RangeSlider/index.ts b/packages/react-components/react-slider/library/src/components/RangeSlider/index.ts index 78f7f9297224d9..ab08dd9f162d07 100644 --- a/packages/react-components/react-slider/library/src/components/RangeSlider/index.ts +++ b/packages/react-components/react-slider/library/src/components/RangeSlider/index.ts @@ -1,5 +1,16 @@ export { RangeSlider } from './RangeSlider'; -export type { RangeSliderProps, RangeSliderState } from './RangeSlider.types'; +export type { + RangeSliderOnChangeData, + RangeSliderProps, + RangeSliderSlots, + RangeSliderState, + RangeSliderValue, +} from './RangeSlider.types'; export { renderRangeSlider_unstable } from './renderRangeSlider'; export { useRangeSlider_unstable } from './useRangeSlider'; -export { rangeSliderClassNames, useRangeSliderStyles_unstable } from './useRangeSliderStyles.styles'; +export { useRangeSliderState_unstable } from './useRangeSliderState'; +export { + rangeSliderClassNames, + rangeSliderCSSVars, + useRangeSliderStyles_unstable, +} from './useRangeSliderStyles.styles'; diff --git a/packages/react-components/react-slider/library/src/components/RangeSlider/renderRangeSlider.tsx b/packages/react-components/react-slider/library/src/components/RangeSlider/renderRangeSlider.tsx index a0f76d77359830..cb55ae51b04dbe 100644 --- a/packages/react-components/react-slider/library/src/components/RangeSlider/renderRangeSlider.tsx +++ b/packages/react-components/react-slider/library/src/components/RangeSlider/renderRangeSlider.tsx @@ -11,6 +11,15 @@ import type { RangeSliderState, RangeSliderSlots } from './RangeSlider.types'; export const renderRangeSlider_unstable = (state: RangeSliderState): JSXElement => { assertSlots(state); - // TODO Add additional slots in the appropriate place - return ; + return ( + + + + + + + + + + ); }; diff --git a/packages/react-components/react-slider/library/src/components/RangeSlider/useRangeSlider.ts b/packages/react-components/react-slider/library/src/components/RangeSlider/useRangeSlider.ts index 92c324a6e90293..e75b5087c95c84 100644 --- a/packages/react-components/react-slider/library/src/components/RangeSlider/useRangeSlider.ts +++ b/packages/react-components/react-slider/library/src/components/RangeSlider/useRangeSlider.ts @@ -1,5 +1,10 @@ +'use client'; + import * as React from 'react'; -import { getIntrinsicElementProps, slot } from '@fluentui/react-utilities'; +import { useFieldControlProps_unstable } from '@fluentui/react-field'; +import { getPartitionedNativeProps, slot, useId, useMergedRefs } from '@fluentui/react-utilities'; +import { useFocusWithin } from '@fluentui/react-tabster'; +import { useRangeSliderState_unstable } from './useRangeSliderState'; import type { RangeSliderProps, RangeSliderState } from './RangeSlider.types'; /** @@ -12,20 +17,65 @@ import type { RangeSliderProps, RangeSliderState } from './RangeSlider.types'; * @param ref - reference to root HTMLDivElement of RangeSlider */ export const useRangeSlider_unstable = (props: RangeSliderProps, ref: React.Ref): RangeSliderState => { - return { - // TODO add appropriate props/defaults + // supportsLabelFor is false because RangeSlider has two elements, so htmlFor cannot target both. + // Consumers should pass aria-labelledby directly to the RangeSlider. + props = useFieldControlProps_unstable(props, { supportsLabelFor: false }); + + const nativeProps = getPartitionedNativeProps({ + props, + primarySlotTagName: 'input', + excludedPropNames: ['onChange', 'size', 'defaultValue', 'value', 'id'], + }); + + const { disabled, vertical = false, size = 'medium', root, rail, startThumb, endThumb, startInput, endInput } = props; + + const startInputId = useId('rangeslider-start-', props.id); + const endInputId = useId('rangeslider-end-', props.id); + + const state: RangeSliderState = { + disabled, + size, + vertical, + value: { start: 0, end: 0 }, components: { - // TODO add each slot's element type or component root: 'div', + rail: 'div', + startThumb: 'div', + endThumb: 'div', + startInput: 'input', + endInput: 'input', }, - // TODO add appropriate slots, for example: - // mySlot: resolveShorthand(props.mySlot), - root: slot.always( - getIntrinsicElementProps('div', { - ref, - ...props, - }), - { elementType: 'div' }, - ), + root: slot.always(root, { + defaultProps: { ...nativeProps.root, ref }, + elementType: 'div', + }), + rail: slot.always(rail, { elementType: 'div' }), + startThumb: slot.always(startThumb, { defaultProps: { role: 'presentation' }, elementType: 'div' }), + endThumb: slot.always(endThumb, { defaultProps: { role: 'presentation' }, elementType: 'div' }), + startInput: slot.always(startInput, { + defaultProps: { + id: startInputId, + ...nativeProps.primary, + type: 'range', + orient: vertical ? 'vertical' : undefined, + }, + elementType: 'input', + }), + endInput: slot.always(endInput, { + defaultProps: { + id: endInputId, + ...nativeProps.primary, + type: 'range', + orient: vertical ? 'vertical' : undefined, + }, + elementType: 'input', + }), }; + + state.startThumb.ref = useMergedRefs(state.startThumb.ref, useFocusWithin()); + state.endThumb.ref = useMergedRefs(state.endThumb.ref, useFocusWithin()); + + useRangeSliderState_unstable(state, props); + + return state; }; diff --git a/packages/react-components/react-slider/library/src/components/RangeSlider/useRangeSliderState.tsx b/packages/react-components/react-slider/library/src/components/RangeSlider/useRangeSliderState.tsx new file mode 100644 index 00000000000000..69fcccd44874aa --- /dev/null +++ b/packages/react-components/react-slider/library/src/components/RangeSlider/useRangeSliderState.tsx @@ -0,0 +1,183 @@ +'use client'; + +import * as React from 'react'; +import { + clamp, + mergeCallbacks, + useControllableState, + useEventCallback, + useMergedRefs, +} from '@fluentui/react-utilities'; +import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; +import { rangeSliderCSSVars } from './useRangeSliderStyles.styles'; +import type { RangeSliderProps, RangeSliderState, RangeSliderValue } from './RangeSlider.types'; + +const { + rangeSliderDirectionVar, + rangeSliderLowerProgressVar, + rangeSliderUpperProgressVar, + rangeSliderStepsPercentVar, +} = rangeSliderCSSVars; + +const getPercent = (value: number, min: number, max: number) => (max === min ? 0 : ((value - min) / (max - min)) * 100); + +const toTuple = (value?: RangeSliderValue): [number, number] | undefined => + value ? [value.start, value.end] : undefined; + +const toRangeValue = (start: number, end: number): RangeSliderValue => ({ start, end }); + +export const useRangeSliderState_unstable = (state: RangeSliderState, props: RangeSliderProps): RangeSliderState => { + 'use no memo'; + + const { min = 0, max = 100, step } = props; + const { dir } = useFluent(); + const { disabled, vertical } = state; + const stepValue = step && step > 0 ? step : 1; + const rangeSpan = max - min; + + const [currentValues, setCurrentValues] = useControllableState<[number, number]>({ + state: toTuple(props.value), + defaultState: toTuple(props.defaultValue), + initialState: [min, Math.min(min + 10, max)], + }); + + // Ensure values are properly ordered and clamped + const [rawStart, rawEnd] = currentValues; + const lowerValue = clamp(Math.min(rawStart, rawEnd), min, max); + const upperValue = clamp(Math.max(rawStart, rawEnd), min, max); + + state.value = toRangeValue(lowerValue, upperValue); + + // Refs for pointer tracking and position calculation + const activeDragThumb = React.useRef<'start' | 'end' | null>(null); + const rootRef = React.useRef(null); + const railRef = React.useRef(null); + + // Shared update function for pointer and input handlers. + const updateValues = useEventCallback((start: number, end: number, event?: React.SyntheticEvent | Event) => { + setCurrentValues([start, end]); + if (event) { + props.onChange?.(event, { + type: 'change', + event: event as React.ChangeEvent, + value: toRangeValue(start, end), + }); + } + }); + + // Convert pointer position to slider value using the rail's bounding rect + // (rail excludes thumb overhang padding, giving accurate 0-100% mapping) + const getValueFromPointer = (clientX: number, clientY: number): number | null => { + const rect = railRef.current?.getBoundingClientRect() ?? rootRef.current?.getBoundingClientRect(); + if (!rect) { + return null; + } + + const size = vertical ? rect.height : rect.width; + const offset = vertical ? rect.bottom - clientY : clientX - rect.left; + const ratio = clamp(offset / size, 0, 1); + const adjustedRatio = !vertical && dir === 'rtl' ? 1 - ratio : ratio; + + return min + adjustedRatio * rangeSpan; + }; + + // Snap value to nearest step (always snaps, using stepValue which defaults to 1) + const snapToStep = (value: number): number => { + if (rangeSpan === 0) { + return min; + } + return clamp(min + Math.round((value - min) / stepValue) * stepValue, min, max); + }; + + // Apply snapped value to the appropriate thumb + const applyValueToThumb = (thumb: 'start' | 'end', value: number) => { + const snapped = snapToStep(value); + const newStart = thumb === 'start' ? Math.min(snapped, upperValue) : lowerValue; + const newEnd = thumb === 'end' ? Math.max(snapped, lowerValue) : upperValue; + updateValues(newStart, newEnd); + }; + + // Pointer handlers for rail click and drag + const handlePointerDown = useEventCallback((ev: React.PointerEvent) => { + if (disabled) { + return; + } + + const value = getValueFromPointer(ev.clientX, ev.clientY); + if (value === null) { + return; + } + + // Determine closest thumb based on distance + const thumb = Math.abs(value - lowerValue) <= Math.abs(value - upperValue) ? 'start' : 'end'; + activeDragThumb.current = thumb; + + ev.preventDefault(); + ev.currentTarget.setPointerCapture(ev.pointerId); + applyValueToThumb(thumb, value); + }); + + const handlePointerMove = useEventCallback((ev: React.PointerEvent) => { + if (disabled || !activeDragThumb.current) { + return; + } + + const value = getValueFromPointer(ev.clientX, ev.clientY); + if (value === null) { + return; + } + + ev.preventDefault(); + applyValueToThumb(activeDragThumb.current, value); + }); + + const handlePointerUp = useEventCallback((ev: React.PointerEvent) => { + if (ev.currentTarget.hasPointerCapture(ev.pointerId)) { + ev.currentTarget.releasePointerCapture(ev.pointerId); + } + activeDragThumb.current = null; + }); + + // CSS variables for thumb positioning + const stepPercent = step && step > 0 && rangeSpan ? `${(step * 100) / rangeSpan}%` : undefined; + + state.root.ref = useMergedRefs(state.root.ref, rootRef); + state.rail.ref = useMergedRefs(state.rail.ref, railRef); + state.root.style = { + [rangeSliderDirectionVar]: vertical ? '0deg' : dir === 'ltr' ? '90deg' : '270deg', + [rangeSliderLowerProgressVar]: `${getPercent(lowerValue, min, max)}%`, + [rangeSliderUpperProgressVar]: `${getPercent(upperValue, min, max)}%`, + ...(stepPercent && { [rangeSliderStepsPercentVar]: stepPercent }), + ...state.root.style, + }; + + state.root.onPointerDown = mergeCallbacks(state.root.onPointerDown, handlePointerDown); + state.root.onPointerMove = mergeCallbacks(state.root.onPointerMove, handlePointerMove); + state.root.onPointerUp = mergeCallbacks(state.root.onPointerUp, handlePointerUp); + state.root.onPointerCancel = mergeCallbacks(state.root.onPointerCancel, handlePointerUp); + + // Input configuration for keyboard accessibility + state.startInput.value = lowerValue; + state.startInput.min = min; + state.startInput.max = upperValue; + state.startInput.step = stepValue; + state.startInput.disabled = disabled; + state.startInput.onChange = mergeCallbacks(state.startInput.onChange, ev => { + if (!disabled) { + updateValues(Math.min(Number(ev.currentTarget.value), upperValue), upperValue, ev); + } + }); + + state.endInput.value = upperValue; + state.endInput.min = lowerValue; + state.endInput.max = max; + state.endInput.step = stepValue; + state.endInput.disabled = disabled; + state.endInput.onChange = mergeCallbacks(state.endInput.onChange, ev => { + if (!disabled) { + updateValues(lowerValue, Math.max(Number(ev.currentTarget.value), lowerValue), ev); + } + }); + + return state; +}; diff --git a/packages/react-components/react-slider/library/src/components/RangeSlider/useRangeSliderStyles.styles.ts b/packages/react-components/react-slider/library/src/components/RangeSlider/useRangeSliderStyles.styles.ts index 39407a58e6112b..a08a888c1d443b 100644 --- a/packages/react-components/react-slider/library/src/components/RangeSlider/useRangeSliderStyles.styles.ts +++ b/packages/react-components/react-slider/library/src/components/RangeSlider/useRangeSliderStyles.styles.ts @@ -1,35 +1,336 @@ +'use client'; + import { makeStyles, mergeClasses } from '@griffel/react'; +import { createFocusOutlineStyle } from '@fluentui/react-tabster'; +import { tokens } from '@fluentui/react-theme'; import type { SlotClassNames } from '@fluentui/react-utilities'; import type { RangeSliderSlots, RangeSliderState } from './RangeSlider.types'; export const rangeSliderClassNames: SlotClassNames = { root: 'fui-RangeSlider', - // TODO: add class names for all slots on RangeSliderSlots. - // Should be of the form `: 'fui-RangeSlider__` + rail: 'fui-RangeSlider__rail', + startThumb: 'fui-RangeSlider__startThumb', + endThumb: 'fui-RangeSlider__endThumb', + startInput: 'fui-RangeSlider__startInput', + endInput: 'fui-RangeSlider__endInput', }; -/** - * Styles for the root slot - */ -const useStyles = makeStyles({ +const startThumbPositionVar = `--fui-RangeSlider__startThumb--position`; +const endThumbPositionVar = `--fui-RangeSlider__endThumb--position`; + +export const rangeSliderCSSVars = { + rangeSliderDirectionVar: `--fui-RangeSlider--direction`, + rangeSliderInnerThumbRadiusVar: `--fui-RangeSlider__inner-thumb--radius`, + rangeSliderLowerProgressVar: `--fui-RangeSlider--lower-progress`, + rangeSliderUpperProgressVar: `--fui-RangeSlider--upper-progress`, + rangeSliderProgressColorVar: `--fui-RangeSlider__progress--color`, + rangeSliderRailSizeVar: `--fui-RangeSlider__rail--size`, + rangeSliderRailColorVar: `--fui-RangeSlider__rail--color`, + rangeSliderStepsPercentVar: `--fui-RangeSlider--steps-percent`, + rangeSliderThumbColorVar: `--fui-RangeSlider__thumb--color`, + rangeSliderThumbSizeVar: `--fui-RangeSlider__thumb--size`, +}; + +const { + rangeSliderDirectionVar, + rangeSliderInnerThumbRadiusVar, + rangeSliderLowerProgressVar, + rangeSliderUpperProgressVar, + rangeSliderProgressColorVar, + rangeSliderRailSizeVar, + rangeSliderRailColorVar, + rangeSliderStepsPercentVar, + rangeSliderThumbColorVar, + rangeSliderThumbSizeVar, +} = rangeSliderCSSVars; + +const useRootStyles = makeStyles({ root: { - // TODO Add default styles for the root element + position: 'relative', + display: 'inline-grid', + touchAction: 'none', + alignItems: 'center', + justifyItems: 'center', + }, + small: { + [rangeSliderThumbSizeVar]: '16px', + [rangeSliderInnerThumbRadiusVar]: '5px', + [rangeSliderRailSizeVar]: '2px', + minHeight: '24px', + }, + medium: { + [rangeSliderThumbSizeVar]: '20px', + [rangeSliderInnerThumbRadiusVar]: '6px', + [rangeSliderRailSizeVar]: '4px', + minHeight: '32px', + }, + horizontal: { + minWidth: '120px', + gridTemplateRows: `1fr var(${rangeSliderThumbSizeVar}) 1fr`, + gridTemplateColumns: `1fr calc(100% - var(${rangeSliderThumbSizeVar})) 1fr`, + }, + vertical: { + minHeight: '120px', + gridTemplateRows: `1fr calc(100% - var(${rangeSliderThumbSizeVar})) 1fr`, + gridTemplateColumns: `1fr var(${rangeSliderThumbSizeVar}) 1fr`, + }, + enabled: { + [rangeSliderRailColorVar]: tokens.colorNeutralStrokeAccessible, + [rangeSliderProgressColorVar]: tokens.colorCompoundBrandBackground, + [rangeSliderThumbColorVar]: tokens.colorCompoundBrandBackground, + ':hover': { + [rangeSliderThumbColorVar]: tokens.colorCompoundBrandBackgroundHover, + [rangeSliderProgressColorVar]: tokens.colorCompoundBrandBackgroundHover, + }, + ':active': { + [rangeSliderThumbColorVar]: tokens.colorCompoundBrandBackgroundPressed, + [rangeSliderProgressColorVar]: tokens.colorCompoundBrandBackgroundPressed, + }, + '@media (forced-colors: active)': { + [rangeSliderRailColorVar]: 'CanvasText', + [rangeSliderThumbColorVar]: 'Highlight', + [rangeSliderProgressColorVar]: 'Highlight', + ':hover': { + [rangeSliderThumbColorVar]: 'Highlight', + [rangeSliderProgressColorVar]: 'Highlight', + }, + }, + }, + disabled: { + [rangeSliderThumbColorVar]: tokens.colorNeutralForegroundDisabled, + [rangeSliderRailColorVar]: tokens.colorNeutralBackgroundDisabled, + [rangeSliderProgressColorVar]: tokens.colorNeutralForegroundDisabled, + '@media (forced-colors: active)': { + [rangeSliderRailColorVar]: 'GrayText', + [rangeSliderThumbColorVar]: 'GrayText', + [rangeSliderProgressColorVar]: 'GrayText', + }, + }, +}); + +const useRailStyles = makeStyles({ + rail: { + borderRadius: tokens.borderRadiusXLarge, + pointerEvents: 'none', + gridRowStart: '2', + gridRowEnd: '2', + gridColumnStart: '2', + gridColumnEnd: '2', + position: 'relative', + forcedColorAdjust: 'none', + backgroundImage: `linear-gradient( + var(${rangeSliderDirectionVar}), + var(${rangeSliderRailColorVar}) 0%, + var(${rangeSliderRailColorVar}) var(${rangeSliderLowerProgressVar}), + var(${rangeSliderProgressColorVar}) var(${rangeSliderLowerProgressVar}), + var(${rangeSliderProgressColorVar}) var(${rangeSliderUpperProgressVar}), + var(${rangeSliderRailColorVar}) var(${rangeSliderUpperProgressVar}) + )`, + outlineWidth: '1px', + outlineStyle: 'solid', + outlineColor: tokens.colorTransparentStroke, + '::before': { + content: "''", + position: 'absolute', + backgroundImage: `repeating-linear-gradient( + var(${rangeSliderDirectionVar}), + #0000 0%, + #0000 calc(var(${rangeSliderStepsPercentVar}) - 1px), + ${tokens.colorNeutralBackground1} calc(var(${rangeSliderStepsPercentVar}) - 1px), + ${tokens.colorNeutralBackground1} var(${rangeSliderStepsPercentVar}) + )`, + '@media (forced-colors: active)': { + backgroundImage: `repeating-linear-gradient( + var(${rangeSliderDirectionVar}), + #0000 0%, + #0000 calc(var(${rangeSliderStepsPercentVar}) - 1px), + HighlightText calc(var(${rangeSliderStepsPercentVar}) - 1px), + HighlightText var(${rangeSliderStepsPercentVar}) + )`, + }, + }, + }, + horizontal: { + width: '100%', + height: `var(${rangeSliderRailSizeVar})`, + '::before': { + left: '-1px', + right: '-1px', + height: `var(${rangeSliderRailSizeVar})`, + }, + }, + vertical: { + width: `var(${rangeSliderRailSizeVar})`, + height: '100%', + '::before': { + width: `var(${rangeSliderRailSizeVar})`, + top: '-1px', + bottom: '-1px', + }, + }, +}); + +const useThumbStyles = makeStyles({ + thumbBase: { + gridRowStart: '2', + gridRowEnd: '2', + gridColumnStart: '2', + gridColumnEnd: '2', + position: 'absolute', + width: `var(${rangeSliderThumbSizeVar})`, + height: `var(${rangeSliderThumbSizeVar})`, + pointerEvents: 'auto', + outlineStyle: 'none', + forcedColorAdjust: 'none', + borderRadius: tokens.borderRadiusCircular, + boxShadow: `0 0 0 calc(var(${rangeSliderThumbSizeVar}) * .2) ${tokens.colorNeutralBackground1} inset`, + backgroundColor: `var(${rangeSliderThumbColorVar})`, + + '::before': { + position: 'absolute', + top: '0px', + left: '0px', + bottom: '0px', + right: '0px', + borderRadius: tokens.borderRadiusCircular, + boxSizing: 'border-box', + content: "''", + border: `calc(var(${rangeSliderThumbSizeVar}) * .05) solid ${tokens.colorNeutralStroke1}`, + }, + }, + disabled: { + '::before': { + border: `calc(var(${rangeSliderThumbSizeVar}) * .05) solid ${tokens.colorNeutralForegroundDisabled}`, + }, + }, + startHorizontal: { + [`${startThumbPositionVar}`]: `clamp(var(${rangeSliderInnerThumbRadiusVar}), var(${rangeSliderLowerProgressVar}), calc(100% - var(${rangeSliderInnerThumbRadiusVar})))`, + transform: 'translateX(-50%)', + left: `var(${startThumbPositionVar})`, }, + endHorizontal: { + [`${endThumbPositionVar}`]: `clamp(var(${rangeSliderInnerThumbRadiusVar}), var(${rangeSliderUpperProgressVar}), calc(100% - var(${rangeSliderInnerThumbRadiusVar})))`, + transform: 'translateX(-50%)', + left: `var(${endThumbPositionVar})`, + }, + startVertical: { + [`${startThumbPositionVar}`]: `clamp(var(${rangeSliderInnerThumbRadiusVar}), var(${rangeSliderLowerProgressVar}), calc(100% - var(${rangeSliderInnerThumbRadiusVar})))`, + transform: 'translateY(50%)', + bottom: `var(${startThumbPositionVar})`, + }, + endVertical: { + [`${endThumbPositionVar}`]: `clamp(var(${rangeSliderInnerThumbRadiusVar}), var(${rangeSliderUpperProgressVar}), calc(100% - var(${rangeSliderInnerThumbRadiusVar})))`, + transform: 'translateY(50%)', + bottom: `var(${endThumbPositionVar})`, + }, + focusIndicatorHorizontal: createFocusOutlineStyle({ + selector: 'focus-within', + style: { + outlineOffset: { top: '0px', bottom: '0px', left: '0px', right: '0px' }, + }, + }), + focusIndicatorVertical: createFocusOutlineStyle({ + selector: 'focus-within', + style: { + outlineOffset: { top: '0px', bottom: '0px', left: '0px', right: '0px' }, + }, + }), +}); - // TODO add additional classes for different states and/or slots +const useInputStyles = makeStyles({ + thumbInput: { + position: 'absolute', + inset: 0, + width: '100%', + height: '100%', + margin: 0, + padding: 0, + border: 0, + backgroundColor: 'transparent', + color: 'transparent', + caretColor: 'transparent', + outlineStyle: 'none', + appearance: 'none', + WebkitAppearance: 'none', + opacity: 0, + cursor: 'pointer', + }, + vertical: { + '-webkit-appearance': 'slider-vertical', + // Workaround to check if the browser supports `writing-mode: vertical-lr` for inputs and input[type=range] specifically. + // We check if the `writing-mode: sideways-lr` is supported as it's newer feature and it means + // that vertical controls should also support `writing-mode: vertical-lr`. + '@supports (writing-mode: sideways-lr)': { + writingMode: 'vertical-lr', + direction: 'rtl', + }, + // Fallback for browsers that don't support `writing-mode: vertical-lr` for inputs + '@supports not (writing-mode: sideways-lr)': { + WebkitAppearance: 'slider-vertical', + }, + }, + disabled: { + cursor: 'default', + }, }); -/** - * Apply styling to the RangeSlider slots based on the state - */ export const useRangeSliderStyles_unstable = (state: RangeSliderState): RangeSliderState => { 'use no memo'; - const styles = useStyles(); - state.root.className = mergeClasses(rangeSliderClassNames.root, styles.root, state.root.className); + const rootStyles = useRootStyles(); + const railStyles = useRailStyles(); + const thumbStyles = useThumbStyles(); + const inputStyles = useInputStyles(); + + state.root.className = mergeClasses( + rangeSliderClassNames.root, + rootStyles.root, + rootStyles[state.size!], + state.vertical ? rootStyles.vertical : rootStyles.horizontal, + state.disabled ? rootStyles.disabled : rootStyles.enabled, + state.root.className, + ); + + state.rail.className = mergeClasses( + rangeSliderClassNames.rail, + railStyles.rail, + state.vertical ? railStyles.vertical : railStyles.horizontal, + state.rail.className, + ); + + state.startThumb.className = mergeClasses( + rangeSliderClassNames.startThumb, + thumbStyles.thumbBase, + state.vertical ? thumbStyles.startVertical : thumbStyles.startHorizontal, + state.vertical ? thumbStyles.focusIndicatorVertical : thumbStyles.focusIndicatorHorizontal, + state.disabled && thumbStyles.disabled, + state.startThumb.className, + ); + + state.endThumb.className = mergeClasses( + rangeSliderClassNames.endThumb, + thumbStyles.thumbBase, + state.vertical ? thumbStyles.endVertical : thumbStyles.endHorizontal, + state.vertical ? thumbStyles.focusIndicatorVertical : thumbStyles.focusIndicatorHorizontal, + state.disabled && thumbStyles.disabled, + state.endThumb.className, + ); + + state.startInput.className = mergeClasses( + rangeSliderClassNames.startInput, + inputStyles.thumbInput, + state.vertical && inputStyles.vertical, + state.disabled && inputStyles.disabled, + state.startInput.className, + ); - // TODO Add class names to slots, for example: - // state.mySlot.className = mergeClasses(styles.mySlot, state.mySlot.className); + state.endInput.className = mergeClasses( + rangeSliderClassNames.endInput, + inputStyles.thumbInput, + state.vertical && inputStyles.vertical, + state.disabled && inputStyles.disabled, + state.endInput.className, + ); return state; }; diff --git a/packages/react-components/react-slider/library/src/index.ts b/packages/react-components/react-slider/library/src/index.ts index 2780e62f53c8fa..5cb4f9291dc763 100644 --- a/packages/react-components/react-slider/library/src/index.ts +++ b/packages/react-components/react-slider/library/src/index.ts @@ -10,9 +10,17 @@ export { export type { SliderOnChangeData, SliderProps, SliderSlots, SliderState } from './Slider'; export { rangeSliderClassNames, + rangeSliderCSSVars, RangeSlider, renderRangeSlider_unstable, useRangeSlider_unstable, + useRangeSliderState_unstable, useRangeSliderStyles_unstable, } from './RangeSlider'; -export type { RangeSliderProps, RangeSliderState } from './RangeSlider'; +export type { + RangeSliderOnChangeData, + RangeSliderProps, + RangeSliderSlots, + RangeSliderState, + RangeSliderValue, +} from './RangeSlider'; diff --git a/packages/react-components/react-slider/stories/src/RangeSlider/RangeSliderBestPractices.md b/packages/react-components/react-slider/stories/src/RangeSlider/RangeSliderBestPractices.md index 08ff8ddeeb5f86..b5ef4cd4640561 100644 --- a/packages/react-components/react-slider/stories/src/RangeSlider/RangeSliderBestPractices.md +++ b/packages/react-components/react-slider/stories/src/RangeSlider/RangeSliderBestPractices.md @@ -1,5 +1,12 @@ ## Best practices -### Do +### Layout -### Don't +- Don't use a slider for binary settings. +- Don't use a continuous slider if the range of values is large. +- Don't use for a range with fewer than three values. +- Sliders are typically horizontal but can be vertical, when needed. + +### Content + +- Use step points if you don't want the slider to allow arbitrary values between minimum and maximum. diff --git a/packages/react-components/react-slider/stories/src/RangeSlider/RangeSliderControlled.stories.tsx b/packages/react-components/react-slider/stories/src/RangeSlider/RangeSliderControlled.stories.tsx new file mode 100644 index 00000000000000..91aa503fb12596 --- /dev/null +++ b/packages/react-components/react-slider/stories/src/RangeSlider/RangeSliderControlled.stories.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; +import type { JSXElement, RangeSliderProps } from '@fluentui/react-components'; +import { Button, Label, RangeSlider, useId } from '@fluentui/react-components'; + +const initialValue: RangeSliderProps['value'] = { start: 25, end: 75 }; + +export const Controlled = (): JSXElement => { + const labelId = useId('rangeslider-controlled-label-'); + const [value, setValue] = React.useState(initialValue); + + const onChange: RangeSliderProps['onChange'] = (_, data) => setValue(data.value); + const reset = () => setValue({ ...initialValue }); + + return ( + <> + + + + + ); +}; + +Controlled.parameters = { + docs: { + description: { + story: + 'Manage RangeSlider values in local state and update them through onChange to build a fully controlled experience.', + }, + }, +}; diff --git a/packages/react-components/react-slider/stories/src/RangeSlider/RangeSliderDefault.stories.tsx b/packages/react-components/react-slider/stories/src/RangeSlider/RangeSliderDefault.stories.tsx index c0a7d90fcd6e3e..b66611b2f9b8b9 100644 --- a/packages/react-components/react-slider/stories/src/RangeSlider/RangeSliderDefault.stories.tsx +++ b/packages/react-components/react-slider/stories/src/RangeSlider/RangeSliderDefault.stories.tsx @@ -1,5 +1,14 @@ import * as React from 'react'; import type { JSXElement } from '@fluentui/react-components'; -import { RangeSlider, RangeSliderProps } from '@fluentui/react-components'; +import { Label, RangeSlider, useId } from '@fluentui/react-components'; -export const Default = (props: Partial): JSXElement => ; +export const Default = (): JSXElement => { + const labelId = useId('rangeslider-label-'); + + return ( + <> + + + + ); +}; diff --git a/packages/react-components/react-slider/stories/src/RangeSlider/RangeSliderDescription.md b/packages/react-components/react-slider/stories/src/RangeSlider/RangeSliderDescription.md index e69de29bb2d1d6..03fe90e178450a 100644 --- a/packages/react-components/react-slider/stories/src/RangeSlider/RangeSliderDescription.md +++ b/packages/react-components/react-slider/stories/src/RangeSlider/RangeSliderDescription.md @@ -0,0 +1 @@ +The RangeSlider exposes the same visual treatment as the single-value Slider but allows people to select both a minimum and maximum value along the rail. Each thumb can be dragged with the mouse, touched, or adjusted with the keyboard, and the component renders two visually hidden (screen-reader only) native inputs so both values participate in form submissions without recreating slider semantics. Use it when a filter or control needs a bounded range rather than a single point. diff --git a/packages/react-components/react-slider/stories/src/RangeSlider/RangeSliderDisabled.stories.tsx b/packages/react-components/react-slider/stories/src/RangeSlider/RangeSliderDisabled.stories.tsx new file mode 100644 index 00000000000000..0a2dbc766a0a85 --- /dev/null +++ b/packages/react-components/react-slider/stories/src/RangeSlider/RangeSliderDisabled.stories.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import type { JSXElement } from '@fluentui/react-components'; +import { Label, RangeSlider, useId } from '@fluentui/react-components'; + +export const Disabled = (): JSXElement => { + const labelId = useId('rangeslider-disabled-label-'); + + return ( + <> + + + + ); +}; + +Disabled.parameters = { + docs: { + description: { + story: 'A disabled RangeSlider ignores pointer, touch, and keyboard interactions.', + }, + }, +}; diff --git a/packages/react-components/react-slider/stories/src/RangeSlider/RangeSliderMinMax.stories.tsx b/packages/react-components/react-slider/stories/src/RangeSlider/RangeSliderMinMax.stories.tsx new file mode 100644 index 00000000000000..7bec4cc3a27cbc --- /dev/null +++ b/packages/react-components/react-slider/stories/src/RangeSlider/RangeSliderMinMax.stories.tsx @@ -0,0 +1,38 @@ +import * as React from 'react'; +import type { JSXElement } from '@fluentui/react-components'; +import { Label, makeStyles, RangeSlider, useId } from '@fluentui/react-components'; + +const useStyles = makeStyles({ + wrapper: { + display: 'flex', + alignItems: 'center', + columnGap: '0.5rem', + }, +}); + +export const MinMax = (): JSXElement => { + const labelId = useId('rangeslider-min-max-label-'); + const styles = useStyles(); + const min = 10; + const max = 60; + + return ( + <> + +
+ + + +
+ + ); +}; + +MinMax.parameters = { + docs: { + description: { + story: + 'Display textual min and max values alongside the RangeSlider to help users understand the available bounds.', + }, + }, +}; diff --git a/packages/react-components/react-slider/stories/src/RangeSlider/RangeSliderSize.stories.tsx b/packages/react-components/react-slider/stories/src/RangeSlider/RangeSliderSize.stories.tsx new file mode 100644 index 00000000000000..1f1bd791fbd580 --- /dev/null +++ b/packages/react-components/react-slider/stories/src/RangeSlider/RangeSliderSize.stories.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; +import type { JSXElement } from '@fluentui/react-components'; +import { Label, RangeSlider, useId } from '@fluentui/react-components'; + +export const Size = (): JSXElement => { + const mediumLabelId = useId('rangeslider-medium-label-'); + const smallLabelId = useId('rangeslider-small-label-'); + + return ( + <> + + + + + + + ); +}; + +Size.parameters = { + docs: { + description: { + story: 'RangeSlider is available in medium and small sizes. Medium is the default.', + }, + }, +}; diff --git a/packages/react-components/react-slider/stories/src/RangeSlider/RangeSliderStep.stories.tsx b/packages/react-components/react-slider/stories/src/RangeSlider/RangeSliderStep.stories.tsx new file mode 100644 index 00000000000000..cdcb9d8519f09d --- /dev/null +++ b/packages/react-components/react-slider/stories/src/RangeSlider/RangeSliderStep.stories.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import type { JSXElement } from '@fluentui/react-components'; +import { Label, RangeSlider, useId } from '@fluentui/react-components'; + +export const Step = (): JSXElement => { + const labelId = useId('rangeslider-step-label-'); + + return ( + <> + + + + ); +}; + +Step.parameters = { + docs: { + description: { + story: 'Use the step prop so the start and end values always land on a multiple of the configured increment.', + }, + }, +}; diff --git a/packages/react-components/react-slider/stories/src/RangeSlider/RangeSliderVertical.stories.tsx b/packages/react-components/react-slider/stories/src/RangeSlider/RangeSliderVertical.stories.tsx new file mode 100644 index 00000000000000..36b9291653d020 --- /dev/null +++ b/packages/react-components/react-slider/stories/src/RangeSlider/RangeSliderVertical.stories.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import type { JSXElement } from '@fluentui/react-components'; +import { Label, RangeSlider, useId } from '@fluentui/react-components'; + +export const Vertical = (): JSXElement => { + const labelId = useId('rangeslider-vertical-label-'); + + return ( + <> + + + + ); +}; + +Vertical.parameters = { + docs: { + description: { + story: 'Set vertical to orient the RangeSlider vertically with the maximum value at the top of the rail.', + }, + }, +}; diff --git a/packages/react-components/react-slider/stories/src/RangeSlider/index.stories.tsx b/packages/react-components/react-slider/stories/src/RangeSlider/index.stories.tsx index aa149af016f5e4..ddc5e09aec56e6 100644 --- a/packages/react-components/react-slider/stories/src/RangeSlider/index.stories.tsx +++ b/packages/react-components/react-slider/stories/src/RangeSlider/index.stories.tsx @@ -1,9 +1,19 @@ +import * as React from 'react'; + +import type { Meta } from '@storybook/react'; + import { RangeSlider } from '@fluentui/react-components'; import descriptionMd from './RangeSliderDescription.md'; import bestPracticesMd from './RangeSliderBestPractices.md'; export { Default } from './RangeSliderDefault.stories'; +export { Size } from './RangeSliderSize.stories'; +export { Controlled } from './RangeSliderControlled.stories'; +export { Step } from './RangeSliderStep.stories'; +export { MinMax } from './RangeSliderMinMax.stories'; +export { Vertical } from './RangeSliderVertical.stories'; +export { Disabled } from './RangeSliderDisabled.stories'; export default { title: 'Components/RangeSlider', @@ -15,4 +25,19 @@ export default { }, }, }, -}; + decorators: [ + Story => ( +
+ +
+ ), + ], +} as Meta;