From 1b233afeaa6d96b5a017a5e9bba2a28ed0f4f6b9 Mon Sep 17 00:00:00 2001 From: Damien Pellier Date: Thu, 2 Oct 2025 10:07:08 +0200 Subject: [PATCH 1/2] feat(range): add custom ticks & correct rect size --- .../components/range-bounds/RangeBounds.tsx | 5 +- .../range-bounds/rangeBounds.module.scss | 4 +- .../src/components/range-thumb/RangeThumb.tsx | 13 +- .../src/components/range-tick/RangeTick.tsx | 87 ++++++++++++ .../range-tick/rangeTick.module.scss | 47 +++++++ .../src/components/range-ticks/RangeTicks.tsx | 37 +++++ .../range-ticks/rangeTicks.module.scss | 5 + .../range/src/components/range/Range.tsx | 128 +++++++----------- .../src/components/range/range.module.scss | 28 +--- .../components/range/src/constants/thumb.ts | 5 + .../range/src/contexts/useRange.tsx | 125 +++++++++++++++++ .../src/components/range/src/dev.stories.tsx | 56 +++++++- .../src/components/range/src/index.ts | 3 +- .../range/tests/rendering/range.e2e.ts | 12 -- .../range/tests/rendering/range.stories.tsx | 8 +- packages/ods-react/src/style/_range.scss | 1 + .../components/range/range.stories.tsx | 53 ++++++-- .../range/technical-information.mdx | 4 + 18 files changed, 476 insertions(+), 145 deletions(-) create mode 100644 packages/ods-react/src/components/range/src/components/range-tick/RangeTick.tsx create mode 100644 packages/ods-react/src/components/range/src/components/range-tick/rangeTick.module.scss create mode 100644 packages/ods-react/src/components/range/src/components/range-ticks/RangeTicks.tsx create mode 100644 packages/ods-react/src/components/range/src/components/range-ticks/rangeTicks.module.scss create mode 100644 packages/ods-react/src/components/range/src/constants/thumb.ts create mode 100644 packages/ods-react/src/components/range/src/contexts/useRange.tsx diff --git a/packages/ods-react/src/components/range/src/components/range-bounds/RangeBounds.tsx b/packages/ods-react/src/components/range/src/components/range-bounds/RangeBounds.tsx index fc01691d9..1e457883b 100644 --- a/packages/ods-react/src/components/range/src/components/range-bounds/RangeBounds.tsx +++ b/packages/ods-react/src/components/range/src/components/range-bounds/RangeBounds.tsx @@ -1,18 +1,19 @@ import classNames from 'classnames'; import { type FC, type JSX } from 'react'; +import { useRange } from '../../contexts/useRange'; import style from './rangeBounds.module.scss'; interface RangeBoundsProp { - disabled?: boolean, max: number, min: number } const RangeBounds: FC = ({ - disabled, max, min, }): JSX.Element => { + const { disabled } = useRange(); + return (
= ({ - disabled, + displayTooltip, index, invalid, }): JSX.Element => { const thumbRef = useRef(null); const fieldContext = useFormField(); const { value } = useSliderContext(); + const { disabled } = useRange(); const [isFocused, setIsFocused] = useState(false); const [isTooltipOpen, setIsTooltipOpen] = useState(false); @@ -39,11 +41,11 @@ const RangeThumb: FC = ({ } return ( - + = ({ onMouseLeave={ () => setIsTooltipOpen(false) } onMouseOver={ () => setIsTooltipOpen(true) } ref={ thumbRef } - role="slider" - > + role="slider"> diff --git a/packages/ods-react/src/components/range/src/components/range-tick/RangeTick.tsx b/packages/ods-react/src/components/range/src/components/range-tick/RangeTick.tsx new file mode 100644 index 000000000..baf6c4677 --- /dev/null +++ b/packages/ods-react/src/components/range/src/components/range-tick/RangeTick.tsx @@ -0,0 +1,87 @@ +import { Slider } from '@ark-ui/react/slider'; +import classNames from 'classnames'; +import { type FC, type JSX, useEffect, useRef } from 'react'; +import { THUMB_SIZE } from '../../constants/thumb'; +import { type RangeTickItem, useRange } from '../../contexts/useRange'; +import style from './rangeTick.module.scss'; + +interface RangeTickProp { + index: number, + isLast: boolean, + singleMode: boolean, + tick: RangeTickItem, +} + +const RangeTick: FC = ({ + index, + isLast, + singleMode, + tick, +}): JSX.Element => { + const { disabled, setRootPadding } = useRange(); + const tickRef = useRef(null); + const isNumber = typeof tick === 'number'; + + useEffect(() => { + if (!tickRef.current || typeof tick === 'number') { + return; + } + + const resizeObserver = new ResizeObserver((entries) => { + if (entries && entries.length) { + const { height, top, width } = entries[0].contentRect; + + if (index === 0) { + setRootPadding((padding) => ({ + ...padding, + left: (width / 2) - (THUMB_SIZE / 2), + })); + } else if (isLast) { + setRootPadding((padding) => ({ + ...padding, + right: (width / 2) - (THUMB_SIZE / 2), + })); + } + + setRootPadding((padding) => ({ + ...padding, + bottom: height + top, + })); + } + }); + + resizeObserver.observe(tickRef.current); + + return () => { + resizeObserver.disconnect(); + }; + }, [index, isLast, setRootPadding, tick, tickRef]); + + return ( + + { + !isNumber && + + { tick.label } + + } + + ); +}; + +RangeTick.displayName = 'RangeTick'; + +export { + RangeTick, + type RangeTickProp, +}; diff --git a/packages/ods-react/src/components/range/src/components/range-tick/rangeTick.module.scss b/packages/ods-react/src/components/range/src/components/range-tick/rangeTick.module.scss new file mode 100644 index 000000000..dde72e37b --- /dev/null +++ b/packages/ods-react/src/components/range/src/components/range-tick/rangeTick.module.scss @@ -0,0 +1,47 @@ +@use 'sass:math'; +@use '../../../../../style/range'; + +@layer ods-atoms { + .range-tick { + $tick-width: 2px; + + position: absolute; + + &::before { + display: block; + position: absolute; + bottom: -(math.div(range.$ods-range-thumb-size, 2) - math.div(range.$ods-range-track-height, 2)); + border-radius: 6px; + background-color: range.$ods-range-background-color; + width: $tick-width; + height: range.$ods-range-thumb-size; + content: ''; + } + + &[data-state="at-value"], + &--single-mode[data-state="under-value"] { + &::before { + background-color: range.$ods-range-background-color-active; + } + } + + &--custom-marker { + padding-top: range.$ods-range-label-padding-top; + + &::before { + top: -(range.$ods-range-track-height + math.div(range.$ods-range-thumb-size - range.$ods-range-track-height, 2)); + bottom: auto; + left: calc(50% - ($tick-width / 2)); + } + } + + &__label { + color: var(--ods-color-text); + font-weight: 600; + + &--disabled { + color: var(--ods-color-text-disabled-default); + } + } + } +} diff --git a/packages/ods-react/src/components/range/src/components/range-ticks/RangeTicks.tsx b/packages/ods-react/src/components/range/src/components/range-ticks/RangeTicks.tsx new file mode 100644 index 000000000..79213b01e --- /dev/null +++ b/packages/ods-react/src/components/range/src/components/range-ticks/RangeTicks.tsx @@ -0,0 +1,37 @@ +import { Slider } from '@ark-ui/react/slider'; +import { type FC, type JSX } from 'react'; +import { type RangeTickItem } from '../../contexts/useRange'; +import { RangeTick } from '../range-tick/RangeTick'; +import style from './rangeTicks.module.scss'; + +interface RangeTicksProp { + singleMode: boolean, + ticks: RangeTickItem[], +} + +const RangeTicks: FC = ({ + singleMode, + ticks, +}): JSX.Element => { + return ( + + { + ticks.map((tick, i) => ( + + )) + } + + ); +}; + +RangeTicks.displayName = 'RangeTicks'; + +export { + RangeTicks, + type RangeTicksProp, +}; diff --git a/packages/ods-react/src/components/range/src/components/range-ticks/rangeTicks.module.scss b/packages/ods-react/src/components/range/src/components/range-ticks/rangeTicks.module.scss new file mode 100644 index 000000000..0f5eb7ade --- /dev/null +++ b/packages/ods-react/src/components/range/src/components/range-ticks/rangeTicks.module.scss @@ -0,0 +1,5 @@ +@layer ods-atoms { + .range-ticks { + position: relative; + } +} diff --git a/packages/ods-react/src/components/range/src/components/range/Range.tsx b/packages/ods-react/src/components/range/src/components/range/Range.tsx index 04d2f4959..15da7e56b 100644 --- a/packages/ods-react/src/components/range/src/components/range/Range.tsx +++ b/packages/ods-react/src/components/range/src/components/range/Range.tsx @@ -1,75 +1,22 @@ import { Slider } from '@ark-ui/react/slider'; import classNames from 'classnames'; -import { type ComponentPropsWithRef, type FC, type JSX, forwardRef, useMemo } from 'react'; +import { type ComponentPropsWithRef, type FC, type JSX, forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react'; import { useFormField } from '../../../../form-field/src'; +import { THUMB_SIZE } from '../../constants/thumb'; +import { RangeProvider, type RangeRootProp, type RangeValueChangeDetail, useRange } from '../../contexts/useRange'; import { RangeBounds } from '../range-bounds/RangeBounds'; import { RangeThumb } from '../range-thumb/RangeThumb'; +import { RangeTicks } from '../range-ticks/RangeTicks'; import { RangeTrack } from '../range-track/RangeTrack'; import style from './range.module.scss'; -interface RangeValueChangeDetail { - value: number[], -} +interface RangeProp extends Omit, 'aria-label' | 'aria-labelledby' | 'defaultValue'>, RangeRootProp {} -interface RangeProp extends Omit, 'aria-label' | 'aria-labelledby' | 'defaultValue'> { - /** - * The aria-label of each slider thumb. Useful for providing an accessible name to the slider. - */ - 'aria-label'?: string[], - /** - * The id of the elements that labels each slider thumb. Useful for providing an accessible name to the slider. - */ - 'aria-labelledby'?: string[], - /** - * The initial selected value(s). Use when you don't need to control the value(s) of the range. - */ - defaultValue?: number[], - /** - * Whether the component is disabled. - */ - disabled?: boolean, - /** - * Whether the component is in error state. - */ - invalid?: boolean, - /** - * The maximum value that can be selected. - */ - max?: number, - /** - * The minimum value that can be selected. - */ - min?: number, - /** - * The name of the form element. Useful for form submission. - */ - name?: string, - /** - * Callback fired when the thumb moves. - */ - onDragging?: (detail: RangeValueChangeDetail) => void, - /** - * Callback fired when the thumb is released. - */ - onValueChange?: (detail: RangeValueChangeDetail) => void, - /** - * The amount to increment or decrement the value by. - */ - step?: number, - /** - * List of tick indicators to display alongside the range. - */ - ticks?: number[], - /** - * The controlled selected value(s). - */ - value?: number[], -} - -const Range: FC = forwardRef(({ +const RangeRoot: FC = forwardRef(({ className, defaultValue, - disabled, + displayBounds = true, + displayTooltip = true, id, invalid, max = 100, @@ -82,7 +29,9 @@ const Range: FC = forwardRef(({ value, ...props }, ref): JSX.Element => { + const { disabled, rootPadding } = useRange(); const fieldContext = useFormField(); + const rangeRef = useRef(null); const isInvalid = useMemo(() => invalid || fieldContext?.invalid, [fieldContext, invalid]); const nbThumb = useMemo(() => { @@ -94,6 +43,16 @@ const Range: FC = forwardRef(({ return 1; }, [defaultValue, value]); + useImperativeHandle(ref, () => rangeRef.current!, [rangeRef]); + + useEffect(() => { + if (rangeRef.current) { + rangeRef.current.style.setProperty('--ods-range-padding-bottom', `${rootPadding.bottom}px`); + rangeRef.current.style.setProperty('--ods-range-padding-left', `${rootPadding.left}px`); + rangeRef.current.style.setProperty('--ods-range-padding-right', `${rootPadding.right}px`); + } + }, [rangeRef, rootPadding]); + return ( = forwardRef(({ orientation="horizontal" onValueChange={ onDragging } onValueChangeEnd={ onValueChange } - ref={ ref } + ref={ rangeRef } role="group" step={ step } thumbSize={{ - height: 16, - width: 16, + height: THUMB_SIZE, + width: THUMB_SIZE, }} value={ value } { ...props }> @@ -126,7 +85,7 @@ const Range: FC = forwardRef(({ { Array.from({ length: nbThumb }).map((_, idx) => ( @@ -136,29 +95,34 @@ const Range: FC = forwardRef(({ { ticks && ticks.length > 0 && - - { - ticks.map((tick) => ( - - )) - } - + } - + { + displayBounds && + + } ); }); +const Range: FC = forwardRef(({ + disabled, + ...props +}, ref): JSX.Element => { + return ( + + + + ); +}); + Range.displayName = 'Range'; export { diff --git a/packages/ods-react/src/components/range/src/components/range/range.module.scss b/packages/ods-react/src/components/range/src/components/range/range.module.scss index ab9aa2b0d..02a276e80 100644 --- a/packages/ods-react/src/components/range/src/components/range/range.module.scss +++ b/packages/ods-react/src/components/range/src/components/range/range.module.scss @@ -3,32 +3,16 @@ @layer ods-atoms { .range { + --ods-range-padding-bottom: 0; + --ods-range-padding-right: 0; + --ods-range-padding-left: 0; + + padding: (math.div(range.$ods-range-thumb-size, 2) - math.div(range.$ods-range-track-height, 2)) var(--ods-range-padding-right) var(--ods-range-padding-bottom) var(--ods-range-padding-left); + &__control { display: flex; align-items: center; z-index: 2; } - - &__ticks { - bottom: math.div(range.$ods-range-thumb-size, 2) + math.div(range.$ods-range-track-height, 2); - - &__tick { - &::after { - display: block; - border-radius: 6px; - background-color: range.$ods-range-background-color; - width: 2px; - height: range.$ods-range-thumb-size; - content: ''; - } - - &[data-state="at-value"], - &--single-mode[data-state="under-value"] { - &::after { - background-color: range.$ods-range-background-color-active; - } - } - } - } } } diff --git a/packages/ods-react/src/components/range/src/constants/thumb.ts b/packages/ods-react/src/components/range/src/constants/thumb.ts new file mode 100644 index 000000000..649f1d556 --- /dev/null +++ b/packages/ods-react/src/components/range/src/constants/thumb.ts @@ -0,0 +1,5 @@ +const THUMB_SIZE = 20; + +export { + THUMB_SIZE, +}; diff --git a/packages/ods-react/src/components/range/src/contexts/useRange.tsx b/packages/ods-react/src/components/range/src/contexts/useRange.tsx new file mode 100644 index 000000000..81dddc80a --- /dev/null +++ b/packages/ods-react/src/components/range/src/contexts/useRange.tsx @@ -0,0 +1,125 @@ +import { type Dispatch, type JSX, type ReactNode, type SetStateAction, createContext, useContext, useState } from 'react'; + +type RangeTickCustomItem = { label: string, value: number }; +type RangeTickItem = number | RangeTickCustomItem; + +type RangePadding = { + bottom: number, + left: number, + right: number, +} + +interface RangeValueChangeDetail { + value: number[], +} + +interface RangeRootProp { + /** + * The aria-label of each slider thumb. Useful for providing an accessible name to the slider. + */ + 'aria-label'?: string[], + /** + * The id of the elements that labels each slider thumb. Useful for providing an accessible name to the slider. + */ + 'aria-labelledby'?: string[], + /** + * The initial selected value(s). Use when you don't need to control the value(s) of the range. + */ + defaultValue?: number[], + /** + * Whether the component is disabled. + */ + disabled?: boolean, + /** + * Whether the range bounds are displayed under the track. + */ + displayBounds?: boolean, + /** + * Whether a tooltip with the current thumb value is displayed on drag. + */ + displayTooltip?: boolean, + /** + * Whether the component is in error state. + */ + invalid?: boolean, + /** + * The maximum value that can be selected. + */ + max?: number, + /** + * The minimum value that can be selected. + */ + min?: number, + /** + * The name of the form element. Useful for form submission. + */ + name?: string, + /** + * Callback fired when the thumb moves. + */ + onDragging?: (detail: RangeValueChangeDetail) => void, + /** + * Callback fired when the thumb is released. + */ + onValueChange?: (detail: RangeValueChangeDetail) => void, + /** + * The amount to increment or decrement the value by. + */ + step?: number, + /** + * List of tick indicators to display alongside the range. + */ + ticks?: RangeTickItem[], + /** + * The controlled selected value(s). + */ + value?: number[], +} + +interface RangeProviderProp extends Pick { + children: ReactNode; +} + +type RangeContextType = Omit & { + rootPadding: RangePadding, + setRootPadding: Dispatch>, +} + +const RangeContext = createContext(undefined); + +function RangeProvider({ children, disabled }: RangeProviderProp): JSX.Element { + const [rootPadding, setRootPadding] = useState({ + bottom: 0, + left: 0, + right: 0, + }); + + return ( + + { children } + + ); +} + +function useRange(): RangeContextType { + const context = useContext(RangeContext); + + if (!context) { + throw new Error('useRange must be used within a RangeProvider'); + } + + return context; +} + +export { + RangeProvider, + type RangeRootProp, + type RangeTickCustomItem, + type RangeTickItem, + type RangeValueChangeDetail, + useRange, +}; diff --git a/packages/ods-react/src/components/range/src/dev.stories.tsx b/packages/ods-react/src/components/range/src/dev.stories.tsx index f8cae6318..0bf2c9668 100644 --- a/packages/ods-react/src/components/range/src/dev.stories.tsx +++ b/packages/ods-react/src/components/range/src/dev.stories.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useMemo, useRef, useState } from 'react'; import { FormField, FormFieldError, FormFieldHelper, FormFieldLabel } from '../../form-field/src'; import { Range, type RangeValueChangeDetail } from '.'; import style from './dev.module.css'; @@ -178,6 +178,18 @@ export const MaxMin = () => ( ); +export const Ref = () => { + const rangeRef = useRef(null); + + return ( + <> + + + + + ); +} + export const States = () => ( <> ( <> +

+ ); + +export const TicksLabels = () => { + const qualityTicks = useMemo(() => [ + { label: 'Very Poor', value: 1 }, + { label: 'Poor', value: 2 }, + { label: 'Average', value: 3 }, + { label: 'Good', value: 4 }, + { label: 'Excellent', value: 5 }, + ], []); + const loadTicks = useMemo(() => [ + { label: 'Low', value: 0 }, + { label: 'Medium', value: 50 }, + { label: 'High', value: 100 }, + ], []); + + return ( + <> + + +

+ + + +

+ + + + ); +} diff --git a/packages/ods-react/src/components/range/src/index.ts b/packages/ods-react/src/components/range/src/index.ts index ca08f15ea..84b7214e8 100644 --- a/packages/ods-react/src/components/range/src/index.ts +++ b/packages/ods-react/src/components/range/src/index.ts @@ -1 +1,2 @@ -export { Range, type RangeProp, type RangeValueChangeDetail } from './components/range/Range'; +export { Range, type RangeProp } from './components/range/Range'; +export { type RangeTickCustomItem, type RangeTickItem, type RangeValueChangeDetail } from './contexts/useRange'; diff --git a/packages/ods-react/src/components/range/tests/rendering/range.e2e.ts b/packages/ods-react/src/components/range/tests/rendering/range.e2e.ts index a2dce7864..6f04a3ff0 100644 --- a/packages/ods-react/src/components/range/tests/rendering/range.e2e.ts +++ b/packages/ods-react/src/components/range/tests/rendering/range.e2e.ts @@ -5,18 +5,6 @@ describe('Range rendering', () => { it('should render the web component', async() => { await gotoStory(page, 'rendering/render'); - expect(await page.waitForSelector('[data-testid="render"]')).not.toBeNull(); expect(await page.waitForSelector('[data-ods="range"]')).not.toBeNull(); }); - - describe('custom style', () => { - it('should render with custom style applied', async() => { - await gotoStory(page, 'rendering/custom-style'); - - const range = await page.waitForSelector('[data-testid="custom-style"]'); - const height = await range?.evaluate((el: Element) => el.getBoundingClientRect().height); - - expect(height).toBe(42); - }); - }); }); diff --git a/packages/ods-react/src/components/range/tests/rendering/range.stories.tsx b/packages/ods-react/src/components/range/tests/rendering/range.stories.tsx index ab5f3502a..e17125ccf 100644 --- a/packages/ods-react/src/components/range/tests/rendering/range.stories.tsx +++ b/packages/ods-react/src/components/range/tests/rendering/range.stories.tsx @@ -5,12 +5,6 @@ export default { title: 'Tests rendering', }; -export const customStyle = () => ( - -); - export const render = () => ( - + ); diff --git a/packages/ods-react/src/style/_range.scss b/packages/ods-react/src/style/_range.scss index 4b38b89d0..34c79cb1e 100644 --- a/packages/ods-react/src/style/_range.scss +++ b/packages/ods-react/src/style/_range.scss @@ -2,6 +2,7 @@ $ods-range-background-color: var(--ods-color-neutral-200); $ods-range-background-color-active: var(--ods-color-primary-500); $ods-range-track-height: 8px; $ods-range-thumb-size: 20px; +$ods-range-label-padding-top: 8px; @mixin ods-range-thumb() { box-sizing: border-box; diff --git a/packages/storybook/stories/components/range/range.stories.tsx b/packages/storybook/stories/components/range/range.stories.tsx index dc20f0794..710a524c3 100644 --- a/packages/storybook/stories/components/range/range.stories.tsx +++ b/packages/storybook/stories/components/range/range.stories.tsx @@ -51,6 +51,18 @@ export const Demo: StoryObj = { }, control: { type: 'boolean' }, }, + displayBounds: { + table: { + category: CONTROL_CATEGORY.general, + }, + control: { type: 'boolean' }, + }, + displayTooltip: { + table: { + category: CONTROL_CATEGORY.general, + }, + control: { type: 'boolean' }, + }, dualRange: { table: { category: CONTROL_CATEGORY.general, @@ -249,23 +261,42 @@ export const Ticks: Story = { ), }; -export const AccessibilityFormField: Story = { +export const TicksLabels: Story = { + decorators: [(story) =>
{ story() }
], globals: { - imports: `import { FormField, FormFieldLabel, Range } from '@ovhcloud/ods-react';`, + imports: `import { Range } from '@ovhcloud/ods-react';`, }, tags: ['!dev'], + parameters: { + docs: { + source: { ...staticSourceRenderConfig() }, + }, + }, render: ({}) => ( - - - Volume - + <> + - - + + ), }; -export const AccessibilityDualRangeFormField: Story = { +export const AccessibilityFormField: Story = { globals: { imports: `import { FormField, FormFieldLabel, Range } from '@ovhcloud/ods-react';`, }, @@ -273,10 +304,10 @@ export const AccessibilityDualRangeFormField: Story = { render: ({}) => ( - Price range + Volume - + ), }; diff --git a/packages/storybook/stories/components/range/technical-information.mdx b/packages/storybook/stories/components/range/technical-information.mdx index 247436265..1a8d9684c 100644 --- a/packages/storybook/stories/components/range/technical-information.mdx +++ b/packages/storybook/stories/components/range/technical-information.mdx @@ -56,6 +56,10 @@ import * as RangeStories from './range.stories'; + + + + This is a controlled component. The final value is only updated when the user releases the mouse button. From a8bc1e20fe5c127cb39c4a15778e80e24ea9e72b Mon Sep 17 00:00:00 2001 From: Damien Pellier Date: Mon, 6 Oct 2025 10:42:25 +0200 Subject: [PATCH 2/2] fix(range): prevent negative horizontal paddings --- .../src/components/range-tick/RangeTick.tsx | 4 +- .../range/tests/rendering/range.e2e.ts | 50 +++++++++++++++++++ .../range/tests/rendering/range.stories.tsx | 32 +++++++++++- 3 files changed, 83 insertions(+), 3 deletions(-) diff --git a/packages/ods-react/src/components/range/src/components/range-tick/RangeTick.tsx b/packages/ods-react/src/components/range/src/components/range-tick/RangeTick.tsx index baf6c4677..6076f71a9 100644 --- a/packages/ods-react/src/components/range/src/components/range-tick/RangeTick.tsx +++ b/packages/ods-react/src/components/range/src/components/range-tick/RangeTick.tsx @@ -34,12 +34,12 @@ const RangeTick: FC = ({ if (index === 0) { setRootPadding((padding) => ({ ...padding, - left: (width / 2) - (THUMB_SIZE / 2), + left: Math.max(0, (width / 2) - (THUMB_SIZE / 2)), })); } else if (isLast) { setRootPadding((padding) => ({ ...padding, - right: (width / 2) - (THUMB_SIZE / 2), + right: Math.max(0, (width / 2) - (THUMB_SIZE / 2)), })); } diff --git a/packages/ods-react/src/components/range/tests/rendering/range.e2e.ts b/packages/ods-react/src/components/range/tests/rendering/range.e2e.ts index 6f04a3ff0..947c7d7ca 100644 --- a/packages/ods-react/src/components/range/tests/rendering/range.e2e.ts +++ b/packages/ods-react/src/components/range/tests/rendering/range.e2e.ts @@ -1,10 +1,60 @@ import 'jest-puppeteer'; +import { type Page } from 'puppeteer'; import { gotoStory } from '../../../../helpers/test'; +async function getRangePadding(page: Page): Promise<{ bottom?: number, left?: number, right?: number, top?: number }> { + return page.evaluate(() => { + function stripPx(value: string): number { + return parseInt(value.split('px')[0], 10); + } + + const range = document.querySelector('[data-ods="range"]'); + + if (!range) { + return {}; + } + + const style = window.getComputedStyle(range); + + return { + bottom: stripPx(style.getPropertyValue('padding-bottom')), + left: stripPx(style.getPropertyValue('padding-left')), + right: stripPx(style.getPropertyValue('padding-right')), + top: stripPx(style.getPropertyValue('padding-top')), + }; + }); +} + describe('Range rendering', () => { it('should render the web component', async() => { await gotoStory(page, 'rendering/render'); expect(await page.waitForSelector('[data-ods="range"]')).not.toBeNull(); }); + + describe('custom ticks', () => { + it('should render with no horizontal padding if bound labels are small', async() => { + await gotoStory(page, 'rendering/custom-small-ticks'); + await page.waitForSelector('[data-ods="range"]'); + + const padding = await getRangePadding(page); + + expect(padding.bottom).toBeGreaterThan(0); + expect(padding.left).toBe(0); + expect(padding.right).toBe(0); + expect(padding.top).toBeGreaterThan(0); + }); + + it('should render with some horizontal padding if bound labels are large', async() => { + await gotoStory(page, 'rendering/custom-large-ticks'); + await page.waitForSelector('[data-ods="range"]'); + + const padding = await getRangePadding(page); + + expect(padding.bottom).toBeGreaterThan(0); + expect(padding.left).toBeGreaterThan(0); + expect(padding.right).toBeGreaterThan(0); + expect(padding.top).toBeGreaterThan(0); + }); + }); }); diff --git a/packages/ods-react/src/components/range/tests/rendering/range.stories.tsx b/packages/ods-react/src/components/range/tests/rendering/range.stories.tsx index e17125ccf..389dcab2a 100644 --- a/packages/ods-react/src/components/range/tests/rendering/range.stories.tsx +++ b/packages/ods-react/src/components/range/tests/rendering/range.stories.tsx @@ -5,6 +5,36 @@ export default { title: 'Tests rendering', }; -export const render = () => ( +export const CustomLargeTicks = () => ( + +); + +export const CustomSmallTicks = () => ( + +); + +export const Render = () => ( );