Skip to content

Commit 37ace62

Browse files
committed
.
1 parent 6257d57 commit 37ace62

File tree

7 files changed

+146
-8
lines changed

7 files changed

+146
-8
lines changed

demo/src/sandboxes/leva-theme/src/App.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ export default function App() {
117117
rootWidth: '280px',
118118
controlWidth: '160px',
119119
scrubberWidth: '8px',
120-
scrubberHeight: '16px',
120+
scrubberHeight: '8px',
121121
rowHeight: '24px',
122122
folderHeight: '20px',
123123
checkboxSize: '16px',

docs/styling.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ const theme = {
3838
rootWidth: '280px',
3939
controlWidth: '160px',
4040
scrubberWidth: '8px',
41-
scrubberHeight: '16px',
41+
scrubberHeight: '8px',
4242
rowHeight: '24px',
4343
folderHeight: '20px',
4444
checkboxSize: '16px',

packages/leva/src/plugins/Number/RangeSlider.tsx

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,48 @@
1-
import React, { useRef } from 'react'
1+
import React, { useRef, useMemo, useState, useEffect } from 'react'
22
import { RangeWrapper, Range, Scrubber, Indicator } from './StyledRange'
33
import { sanitizeStep } from './number-plugin'
44
import { useDrag } from '../../hooks'
55
import { invertedRange, range } from '../../utils'
66
import { useTh } from '../../styles'
77
import type { RangeSliderProps } from './number-types'
88

9+
// ===========================================
10+
// STEP VISUALIZATION CONFIGURATION
11+
// ===========================================
12+
13+
// Minimum spacing between step indicators in pixels
14+
// - Set to 0 to always show step visualization
15+
// - Increase (e.g., 5-10) to reduce visual clutter when steps are dense
16+
const MIN_STEP_SPACING_PX = 3
17+
18+
// Visualization mode - CHANGE THIS TO SWITCH MODES:
19+
// - 'lines': Vertical lines inside the range bar (subtle, integrated)
20+
// - 'dots': Circles below the range bar (prominent, separated)
21+
type StepVisualizationMode = 'lines' | 'dots'
22+
const STEP_VISUALIZATION_MODE: StepVisualizationMode = 'dots'
23+
924
export function RangeSlider({ value, min, max, onDrag, step, initialValue }: RangeSliderProps) {
1025
const ref = useRef<HTMLDivElement>(null)
1126
const scrubberRef = useRef<HTMLDivElement>(null)
1227
const rangeWidth = useRef<number>(0)
1328
const scrubberWidth = useTh('sizes', 'scrubberWidth')
29+
const [elementWidth, setElementWidth] = useState(0)
30+
31+
useEffect(() => {
32+
if (ref.current) {
33+
const updateWidth = () => {
34+
const { width } = ref.current!.getBoundingClientRect()
35+
setElementWidth(width)
36+
}
37+
updateWidth()
38+
39+
// Update width on resize
40+
const resizeObserver = new ResizeObserver(updateWidth)
41+
resizeObserver.observe(ref.current)
42+
43+
return () => resizeObserver.disconnect()
44+
}
45+
}, [])
1446

1547
const bind = useDrag(({ event, first, xy: [x], movement: [mx], memo }) => {
1648
if (first) {
@@ -29,12 +61,76 @@ export function RangeSlider({ value, min, max, onDrag, step, initialValue }: Ran
2961

3062
const pos = range(value, min, max)
3163

64+
// Calculate step lines for visualization
65+
const stepLines = useMemo(() => {
66+
if (!step || !Number.isFinite(min) || !Number.isFinite(max) || elementWidth === 0) return []
67+
68+
const rangeSpan = max - min
69+
const stepCount = Math.floor(rangeSpan / step)
70+
71+
if (stepCount <= 1) return []
72+
73+
// Calculate step spacing in pixels
74+
const stepSpacingPx = (elementWidth * step) / rangeSpan
75+
76+
// Don't show step lines if they would be too close together
77+
if (stepSpacingPx < MIN_STEP_SPACING_PX) return []
78+
79+
const lines = []
80+
for (let i = 1; i < stepCount; i++) {
81+
const stepValue = min + i * step
82+
const stepPos = range(stepValue, min, max)
83+
lines.push(stepPos * 100) // Convert to percentage
84+
}
85+
86+
return lines
87+
}, [step, min, max, elementWidth])
88+
3289
return (
3390
<RangeWrapper ref={ref} {...bind()}>
3491
<Range>
92+
{stepLines.length > 0 && STEP_VISUALIZATION_MODE === 'lines' && (
93+
<svg
94+
style={{
95+
position: 'absolute',
96+
top: 0,
97+
left: 0,
98+
width: '100%',
99+
height: '100%',
100+
pointerEvents: 'none',
101+
}}>
102+
{stepLines.map((linePos, index) => (
103+
<line
104+
key={index}
105+
x1={`${linePos}%`}
106+
y1="0"
107+
x2={`${linePos}%`}
108+
y2="100%"
109+
stroke="currentColor"
110+
strokeWidth="1"
111+
opacity="0.2"
112+
/>
113+
))}
114+
</svg>
115+
)}
116+
{stepLines.length > 0 && STEP_VISUALIZATION_MODE === 'dots' && (
117+
<svg
118+
style={{
119+
position: 'absolute',
120+
top: '100%',
121+
left: 0,
122+
width: '100%',
123+
height: '8px',
124+
pointerEvents: 'none',
125+
}}>
126+
{stepLines.map((dotPos, index) => (
127+
<circle key={index} cx={`${dotPos}%`} cy="4" r="1.25" fill="currentColor" opacity="0.4" />
128+
))}
129+
</svg>
130+
)}
35131
<Indicator style={{ left: 0, right: `${(1 - pos) * 100}%` }} />
36132
</Range>
37-
<Scrubber ref={scrubberRef} style={{ left: `calc(${pos} * (100% - ${scrubberWidth}))` }} />
133+
<Scrubber ref={scrubberRef} style={{ left: `calc((${pos} * 100%) - (var(--leva-sizes-scrubberWidth) / 2))` }} />
38134
</RangeWrapper>
39135
)
40136
}

packages/leva/src/plugins/Number/StyledRange.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { styled } from '../../styles'
33
export const Range = styled('div', {
44
position: 'relative',
55
width: '100%',
6-
height: 2,
6+
height: 4,
77
borderRadius: '$xs',
88
backgroundColor: '$elevation1',
99
})
@@ -12,10 +12,12 @@ export const Scrubber = styled('div', {
1212
position: 'absolute',
1313
width: '$scrubberWidth',
1414
height: '$scrubberHeight',
15-
borderRadius: '$xs',
15+
borderRadius: '$sm',
1616
boxShadow: '0 0 0 2px $colors$elevation2',
1717
backgroundColor: '$accent2',
1818
cursor: 'pointer',
19+
opacity: 0,
20+
transition: 'opacity 0.15s ease',
1921
$active: 'none $accent1',
2022
$hover: 'none $accent3',
2123
variants: {
@@ -40,10 +42,16 @@ export const RangeWrapper = styled('div', {
4042
height: '100%',
4143
cursor: 'pointer',
4244
touchAction: 'none',
45+
46+
// Show scrubber when wrapper is hovered
47+
[`&:hover ${Scrubber}`]: {
48+
opacity: 1,
49+
},
4350
})
4451

4552
export const Indicator = styled('div', {
4653
position: 'absolute',
4754
height: '100%',
4855
backgroundColor: '$accent2',
56+
borderRadius: '$xs',
4957
})

packages/leva/src/plugins/Number/number-plugin.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,5 +61,5 @@ export const sanitizeStep = (
6161
{ step, initialValue }: Pick<InternalNumberSettings, 'step' | 'initialValue'>
6262
) => {
6363
const steps = Math.round((v - initialValue) / step)
64-
return initialValue + steps * step!
64+
return initialValue + steps * step
6565
}

packages/leva/src/styles/stitches.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export const getDefaultTheme = () => ({
4242
controlWidth: '160px',
4343
numberInputMinWidth: '38px',
4444
scrubberWidth: '8px',
45-
scrubberHeight: '16px',
45+
scrubberHeight: '8px',
4646
rowHeight: '24px',
4747
folderTitleHeight: '20px',
4848
checkboxSize: '16px',

packages/leva/stories/inputs/Number.stories.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,3 +119,37 @@ Complete.play = async ({ canvasElement }) => {
119119
// Verify the story renders
120120
await expect(canvas.getByText(/5/)).toBeInTheDocument()
121121
}
122+
123+
// Multiple controls to test step visualization in context
124+
const MultipleTemplate: StoryFn = () => {
125+
const values = useControls({
126+
wideSteps: { value: 4, min: 0, max: 20, step: 2 },
127+
mediumSteps: { value: 2.5, min: 0, max: 8, step: 0.5 },
128+
fineSteps: { value: 1.25, min: 0, max: 5, step: 0.25 },
129+
denseSteps: { value: 50, min: 0, max: 100, step: 1 },
130+
veryDenseSteps: { value: 0.5, min: 0, max: 1, step: 0.01 },
131+
})
132+
133+
return (
134+
<div>
135+
<pre>{JSON.stringify(values, null, ' ')}</pre>
136+
</div>
137+
)
138+
}
139+
140+
export const StepVisualizationShowcase = MultipleTemplate.bind({})
141+
StepVisualizationShowcase.storyName = 'Step Visualization - All Scenarios'
142+
StepVisualizationShowcase.play = async ({ canvasElement }) => {
143+
const canvas = within(canvasElement)
144+
145+
await waitFor(() => {
146+
expect(within(document.body).getByLabelText(/wideSteps/i)).toBeInTheDocument()
147+
})
148+
149+
// Verify multiple controls render
150+
await expect(canvas.getByText(/"wideSteps"/)).toBeInTheDocument()
151+
await expect(canvas.getByText(/"mediumSteps"/)).toBeInTheDocument()
152+
await expect(canvas.getByText(/"fineSteps"/)).toBeInTheDocument()
153+
await expect(canvas.getByText(/"denseSteps"/)).toBeInTheDocument()
154+
await expect(canvas.getByText(/"veryDenseSteps"/)).toBeInTheDocument()
155+
}

0 commit comments

Comments
 (0)