From 37ace62b65808f316f8d44a3d71da6fdbfc52d94 Mon Sep 17 00:00:00 2001 From: Gianmarco Date: Tue, 4 Nov 2025 16:13:48 +0100 Subject: [PATCH 1/2] . --- demo/src/sandboxes/leva-theme/src/App.jsx | 2 +- docs/styling.md | 2 +- .../leva/src/plugins/Number/RangeSlider.tsx | 100 +++++++++++++++++- .../leva/src/plugins/Number/StyledRange.ts | 12 ++- .../leva/src/plugins/Number/number-plugin.ts | 2 +- packages/leva/src/styles/stitches.config.ts | 2 +- .../leva/stories/inputs/Number.stories.tsx | 34 ++++++ 7 files changed, 146 insertions(+), 8 deletions(-) diff --git a/demo/src/sandboxes/leva-theme/src/App.jsx b/demo/src/sandboxes/leva-theme/src/App.jsx index c5bfdd0a..a633e2ce 100644 --- a/demo/src/sandboxes/leva-theme/src/App.jsx +++ b/demo/src/sandboxes/leva-theme/src/App.jsx @@ -117,7 +117,7 @@ export default function App() { rootWidth: '280px', controlWidth: '160px', scrubberWidth: '8px', - scrubberHeight: '16px', + scrubberHeight: '8px', rowHeight: '24px', folderHeight: '20px', checkboxSize: '16px', diff --git a/docs/styling.md b/docs/styling.md index 9edd8145..de23b173 100644 --- a/docs/styling.md +++ b/docs/styling.md @@ -38,7 +38,7 @@ const theme = { rootWidth: '280px', controlWidth: '160px', scrubberWidth: '8px', - scrubberHeight: '16px', + scrubberHeight: '8px', rowHeight: '24px', folderHeight: '20px', checkboxSize: '16px', diff --git a/packages/leva/src/plugins/Number/RangeSlider.tsx b/packages/leva/src/plugins/Number/RangeSlider.tsx index 6ecf3487..84a0a7eb 100644 --- a/packages/leva/src/plugins/Number/RangeSlider.tsx +++ b/packages/leva/src/plugins/Number/RangeSlider.tsx @@ -1,4 +1,4 @@ -import React, { useRef } from 'react' +import React, { useRef, useMemo, useState, useEffect } from 'react' import { RangeWrapper, Range, Scrubber, Indicator } from './StyledRange' import { sanitizeStep } from './number-plugin' import { useDrag } from '../../hooks' @@ -6,11 +6,43 @@ import { invertedRange, range } from '../../utils' import { useTh } from '../../styles' import type { RangeSliderProps } from './number-types' +// =========================================== +// STEP VISUALIZATION CONFIGURATION +// =========================================== + +// Minimum spacing between step indicators in pixels +// - Set to 0 to always show step visualization +// - Increase (e.g., 5-10) to reduce visual clutter when steps are dense +const MIN_STEP_SPACING_PX = 3 + +// Visualization mode - CHANGE THIS TO SWITCH MODES: +// - 'lines': Vertical lines inside the range bar (subtle, integrated) +// - 'dots': Circles below the range bar (prominent, separated) +type StepVisualizationMode = 'lines' | 'dots' +const STEP_VISUALIZATION_MODE: StepVisualizationMode = 'dots' + export function RangeSlider({ value, min, max, onDrag, step, initialValue }: RangeSliderProps) { const ref = useRef(null) const scrubberRef = useRef(null) const rangeWidth = useRef(0) const scrubberWidth = useTh('sizes', 'scrubberWidth') + const [elementWidth, setElementWidth] = useState(0) + + useEffect(() => { + if (ref.current) { + const updateWidth = () => { + const { width } = ref.current!.getBoundingClientRect() + setElementWidth(width) + } + updateWidth() + + // Update width on resize + const resizeObserver = new ResizeObserver(updateWidth) + resizeObserver.observe(ref.current) + + return () => resizeObserver.disconnect() + } + }, []) const bind = useDrag(({ event, first, xy: [x], movement: [mx], memo }) => { if (first) { @@ -29,12 +61,76 @@ export function RangeSlider({ value, min, max, onDrag, step, initialValue }: Ran const pos = range(value, min, max) + // Calculate step lines for visualization + const stepLines = useMemo(() => { + if (!step || !Number.isFinite(min) || !Number.isFinite(max) || elementWidth === 0) return [] + + const rangeSpan = max - min + const stepCount = Math.floor(rangeSpan / step) + + if (stepCount <= 1) return [] + + // Calculate step spacing in pixels + const stepSpacingPx = (elementWidth * step) / rangeSpan + + // Don't show step lines if they would be too close together + if (stepSpacingPx < MIN_STEP_SPACING_PX) return [] + + const lines = [] + for (let i = 1; i < stepCount; i++) { + const stepValue = min + i * step + const stepPos = range(stepValue, min, max) + lines.push(stepPos * 100) // Convert to percentage + } + + return lines + }, [step, min, max, elementWidth]) + return ( + {stepLines.length > 0 && STEP_VISUALIZATION_MODE === 'lines' && ( + + {stepLines.map((linePos, index) => ( + + ))} + + )} + {stepLines.length > 0 && STEP_VISUALIZATION_MODE === 'dots' && ( + + {stepLines.map((dotPos, index) => ( + + ))} + + )} - + ) } diff --git a/packages/leva/src/plugins/Number/StyledRange.ts b/packages/leva/src/plugins/Number/StyledRange.ts index e770e2ab..57acf057 100644 --- a/packages/leva/src/plugins/Number/StyledRange.ts +++ b/packages/leva/src/plugins/Number/StyledRange.ts @@ -3,7 +3,7 @@ import { styled } from '../../styles' export const Range = styled('div', { position: 'relative', width: '100%', - height: 2, + height: 4, borderRadius: '$xs', backgroundColor: '$elevation1', }) @@ -12,10 +12,12 @@ export const Scrubber = styled('div', { position: 'absolute', width: '$scrubberWidth', height: '$scrubberHeight', - borderRadius: '$xs', + borderRadius: '$sm', boxShadow: '0 0 0 2px $colors$elevation2', backgroundColor: '$accent2', cursor: 'pointer', + opacity: 0, + transition: 'opacity 0.15s ease', $active: 'none $accent1', $hover: 'none $accent3', variants: { @@ -40,10 +42,16 @@ export const RangeWrapper = styled('div', { height: '100%', cursor: 'pointer', touchAction: 'none', + + // Show scrubber when wrapper is hovered + [`&:hover ${Scrubber}`]: { + opacity: 1, + }, }) export const Indicator = styled('div', { position: 'absolute', height: '100%', backgroundColor: '$accent2', + borderRadius: '$xs', }) diff --git a/packages/leva/src/plugins/Number/number-plugin.ts b/packages/leva/src/plugins/Number/number-plugin.ts index a97cdabc..f17b6a14 100644 --- a/packages/leva/src/plugins/Number/number-plugin.ts +++ b/packages/leva/src/plugins/Number/number-plugin.ts @@ -61,5 +61,5 @@ export const sanitizeStep = ( { step, initialValue }: Pick ) => { const steps = Math.round((v - initialValue) / step) - return initialValue + steps * step! + return initialValue + steps * step } diff --git a/packages/leva/src/styles/stitches.config.ts b/packages/leva/src/styles/stitches.config.ts index 47819183..e0f32fbb 100644 --- a/packages/leva/src/styles/stitches.config.ts +++ b/packages/leva/src/styles/stitches.config.ts @@ -42,7 +42,7 @@ export const getDefaultTheme = () => ({ controlWidth: '160px', numberInputMinWidth: '38px', scrubberWidth: '8px', - scrubberHeight: '16px', + scrubberHeight: '8px', rowHeight: '24px', folderTitleHeight: '20px', checkboxSize: '16px', diff --git a/packages/leva/stories/inputs/Number.stories.tsx b/packages/leva/stories/inputs/Number.stories.tsx index 38b290a7..0c087ba2 100644 --- a/packages/leva/stories/inputs/Number.stories.tsx +++ b/packages/leva/stories/inputs/Number.stories.tsx @@ -119,3 +119,37 @@ Complete.play = async ({ canvasElement }) => { // Verify the story renders await expect(canvas.getByText(/5/)).toBeInTheDocument() } + +// Multiple controls to test step visualization in context +const MultipleTemplate: StoryFn = () => { + const values = useControls({ + wideSteps: { value: 4, min: 0, max: 20, step: 2 }, + mediumSteps: { value: 2.5, min: 0, max: 8, step: 0.5 }, + fineSteps: { value: 1.25, min: 0, max: 5, step: 0.25 }, + denseSteps: { value: 50, min: 0, max: 100, step: 1 }, + veryDenseSteps: { value: 0.5, min: 0, max: 1, step: 0.01 }, + }) + + return ( +
+
{JSON.stringify(values, null, '  ')}
+
+ ) +} + +export const StepVisualizationShowcase = MultipleTemplate.bind({}) +StepVisualizationShowcase.storyName = 'Step Visualization - All Scenarios' +StepVisualizationShowcase.play = async ({ canvasElement }) => { + const canvas = within(canvasElement) + + await waitFor(() => { + expect(within(document.body).getByLabelText(/wideSteps/i)).toBeInTheDocument() + }) + + // Verify multiple controls render + await expect(canvas.getByText(/"wideSteps"/)).toBeInTheDocument() + await expect(canvas.getByText(/"mediumSteps"/)).toBeInTheDocument() + await expect(canvas.getByText(/"fineSteps"/)).toBeInTheDocument() + await expect(canvas.getByText(/"denseSteps"/)).toBeInTheDocument() + await expect(canvas.getByText(/"veryDenseSteps"/)).toBeInTheDocument() +} From 9493b970df234bc47e4a47d8bd5fdb5c2852ceb9 Mon Sep 17 00:00:00 2001 From: Gianmarco Date: Wed, 5 Nov 2025 17:28:24 +0100 Subject: [PATCH 2/2] minor story change --- .../leva/stories/inputs/Number.stories.tsx | 69 ++++++++++--------- 1 file changed, 35 insertions(+), 34 deletions(-) diff --git a/packages/leva/stories/inputs/Number.stories.tsx b/packages/leva/stories/inputs/Number.stories.tsx index 0c087ba2..5643f76e 100644 --- a/packages/leva/stories/inputs/Number.stories.tsx +++ b/packages/leva/stories/inputs/Number.stories.tsx @@ -88,6 +88,41 @@ Step.play = async ({ canvasElement }) => { await expect(canvas.getByText(/10/)).toBeInTheDocument() } +// Multiple controls to test step visualization in context +const StepComplexTemplate: StoryFn = () => { + const values = useControls({ + noStep: { value: 20, min: 0, max: 100 }, + wideSteps: { value: 4, min: 0, max: 20, step: 2 }, + mediumSteps: { value: 2.5, min: 0, max: 8, step: 0.5 }, + fineSteps: { value: 1.25, min: 0, max: 5, step: 0.25 }, + denseSteps: { value: 50, min: 0, max: 100, step: 1 }, + veryDenseSteps: { value: 0.5, min: 0, max: 1, step: 0.01 }, + }) + + return ( +
+
{JSON.stringify(values, null, '  ')}
+
+ ) +} + +export const StepComplex = StepComplexTemplate.bind({}) +StepComplex.storyName = 'Step Sliders' +StepComplex.play = async ({ canvasElement }) => { + const canvas = within(canvasElement) + + await waitFor(() => { + expect(within(document.body).getByLabelText(/wideSteps/i)).toBeInTheDocument() + }) + + // Verify multiple controls render + await expect(canvas.getByText(/"wideSteps"/)).toBeInTheDocument() + await expect(canvas.getByText(/"mediumSteps"/)).toBeInTheDocument() + await expect(canvas.getByText(/"fineSteps"/)).toBeInTheDocument() + await expect(canvas.getByText(/"denseSteps"/)).toBeInTheDocument() + await expect(canvas.getByText(/"veryDenseSteps"/)).toBeInTheDocument() +} + export const Suffix = Template.bind({}) Suffix.args = { value: '10px' } Suffix.play = async ({ canvasElement }) => { @@ -119,37 +154,3 @@ Complete.play = async ({ canvasElement }) => { // Verify the story renders await expect(canvas.getByText(/5/)).toBeInTheDocument() } - -// Multiple controls to test step visualization in context -const MultipleTemplate: StoryFn = () => { - const values = useControls({ - wideSteps: { value: 4, min: 0, max: 20, step: 2 }, - mediumSteps: { value: 2.5, min: 0, max: 8, step: 0.5 }, - fineSteps: { value: 1.25, min: 0, max: 5, step: 0.25 }, - denseSteps: { value: 50, min: 0, max: 100, step: 1 }, - veryDenseSteps: { value: 0.5, min: 0, max: 1, step: 0.01 }, - }) - - return ( -
-
{JSON.stringify(values, null, '  ')}
-
- ) -} - -export const StepVisualizationShowcase = MultipleTemplate.bind({}) -StepVisualizationShowcase.storyName = 'Step Visualization - All Scenarios' -StepVisualizationShowcase.play = async ({ canvasElement }) => { - const canvas = within(canvasElement) - - await waitFor(() => { - expect(within(document.body).getByLabelText(/wideSteps/i)).toBeInTheDocument() - }) - - // Verify multiple controls render - await expect(canvas.getByText(/"wideSteps"/)).toBeInTheDocument() - await expect(canvas.getByText(/"mediumSteps"/)).toBeInTheDocument() - await expect(canvas.getByText(/"fineSteps"/)).toBeInTheDocument() - await expect(canvas.getByText(/"denseSteps"/)).toBeInTheDocument() - await expect(canvas.getByText(/"veryDenseSteps"/)).toBeInTheDocument() -}