Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion demo/src/sandboxes/leva-theme/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export default function App() {
rootWidth: '280px',
controlWidth: '160px',
scrubberWidth: '8px',
scrubberHeight: '16px',
scrubberHeight: '8px',
rowHeight: '24px',
folderHeight: '20px',
checkboxSize: '16px',
Expand Down
2 changes: 1 addition & 1 deletion docs/styling.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const theme = {
rootWidth: '280px',
controlWidth: '160px',
scrubberWidth: '8px',
scrubberHeight: '16px',
scrubberHeight: '8px',
rowHeight: '24px',
folderHeight: '20px',
checkboxSize: '16px',
Expand Down
100 changes: 98 additions & 2 deletions packages/leva/src/plugins/Number/RangeSlider.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,48 @@
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'
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<HTMLDivElement>(null)
const scrubberRef = useRef<HTMLDivElement>(null)
const rangeWidth = useRef<number>(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) {
Expand All @@ -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 (
<RangeWrapper ref={ref} {...bind()}>
<Range>
{stepLines.length > 0 && STEP_VISUALIZATION_MODE === 'lines' && (
<svg
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
}}>
{stepLines.map((linePos, index) => (
<line
key={index}
x1={`${linePos}%`}
y1="0"
x2={`${linePos}%`}
y2="100%"
stroke="currentColor"
strokeWidth="1"
opacity="0.2"
/>
))}
</svg>
)}
{stepLines.length > 0 && STEP_VISUALIZATION_MODE === 'dots' && (
<svg
style={{
position: 'absolute',
top: '100%',
left: 0,
width: '100%',
height: '8px',
pointerEvents: 'none',
}}>
{stepLines.map((dotPos, index) => (
<circle key={index} cx={`${dotPos}%`} cy="4" r="1.25" fill="currentColor" opacity="0.4" />
))}
</svg>
)}
<Indicator style={{ left: 0, right: `${(1 - pos) * 100}%` }} />
</Range>
<Scrubber ref={scrubberRef} style={{ left: `calc(${pos} * (100% - ${scrubberWidth}))` }} />
<Scrubber ref={scrubberRef} style={{ left: `calc((${pos} * 100%) - (var(--leva-sizes-scrubberWidth) / 2))` }} />
</RangeWrapper>
)
}
12 changes: 10 additions & 2 deletions packages/leva/src/plugins/Number/StyledRange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
})
Expand All @@ -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: {
Expand All @@ -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',
})
2 changes: 1 addition & 1 deletion packages/leva/src/plugins/Number/number-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,5 +61,5 @@ export const sanitizeStep = (
{ step, initialValue }: Pick<InternalNumberSettings, 'step' | 'initialValue'>
) => {
const steps = Math.round((v - initialValue) / step)
return initialValue + steps * step!
return initialValue + steps * step
}
2 changes: 1 addition & 1 deletion packages/leva/src/styles/stitches.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export const getDefaultTheme = () => ({
controlWidth: '160px',
numberInputMinWidth: '38px',
scrubberWidth: '8px',
scrubberHeight: '16px',
scrubberHeight: '8px',
rowHeight: '24px',
folderTitleHeight: '20px',
checkboxSize: '16px',
Expand Down
35 changes: 35 additions & 0 deletions packages/leva/stories/inputs/Number.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div>
<pre>{JSON.stringify(values, null, ' ')}</pre>
</div>
)
}

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 }) => {
Expand Down
Loading