feat(worksheets): replace digit selector with Radix double-thumbed slider

Replaced button groups with @radix-ui/react-slider for min/max digit
range selection. Features:

- Double-thumbed range slider (1-5 digits)
- Tick marks above slider showing digit values
- Visual feedback (grab cursor, scale on hover, focus rings)
- More intuitive UX for selecting digit ranges

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-11-07 22:08:49 -06:00
parent 65e272c570
commit c0298cf65d
1 changed files with 173 additions and 4 deletions

View File

@ -30,6 +30,7 @@ import {
type DifficultyMode,
} from '../difficultyProfiles'
import { defaultAdditionConfig } from '../../config-schemas'
import type { DisplayRules } from '../displayRules'
interface ConfigPanelProps {
formState: WorksheetFormState
@ -381,6 +382,174 @@ export function ConfigPanel({ formState, onChange }: ConfigPanelProps) {
})}
/>
{/* Digit Range Selector */}
<div
data-section="digit-range"
className={css({
bg: 'gray.50',
border: '1px solid',
borderColor: 'gray.200',
rounded: 'xl',
p: '4',
})}
>
<div className={css({ mb: '3' })}>
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
})}
>
<label className={css({ fontSize: 'sm', fontWeight: 'semibold', color: 'gray.700' })}>
Problem Size (Digits per Number)
</label>
<span className={css({ fontSize: 'sm', fontWeight: 'bold', color: 'brand.600' })}>
{(() => {
const min = formState.digitRange?.min ?? 2
const max = formState.digitRange?.max ?? 2
return min === max ? `${min}` : `${min}-${max}`
})()}
</span>
</div>
<p className={css({ fontSize: 'xs', color: 'gray.500', mt: '1' })}>
{(() => {
const min = formState.digitRange?.min ?? 2
const max = formState.digitRange?.max ?? 2
return min === max
? `All problems: exactly ${min} digit${min > 1 ? 's' : ''}`
: `Mixed problem sizes from ${min} to ${max} digits`
})()}
</p>
</div>
{/* Range Slider with Tick Marks */}
<div className={css({ position: 'relative', px: '3', py: '4' })}>
{/* Tick marks */}
<div
className={css({
position: 'absolute',
width: 'full',
top: '0',
left: '0',
px: '3',
display: 'flex',
justifyContent: 'space-between',
})}
>
{[1, 2, 3, 4, 5].map((digit) => (
<div
key={`tick-${digit}`}
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
width: '0',
})}
>
<div
className={css({
fontSize: '2xs',
fontWeight: 'medium',
color: 'gray.600',
mb: '1',
})}
>
{digit}
</div>
<div
className={css({
width: '1px',
height: '2',
bg: 'gray.300',
})}
/>
</div>
))}
</div>
{/* Double-thumbed range slider */}
<Slider.Root
className={css({
position: 'relative',
display: 'flex',
alignItems: 'center',
userSelect: 'none',
touchAction: 'none',
width: 'full',
height: '6',
mt: '8',
})}
value={[formState.digitRange?.min ?? 2, formState.digitRange?.max ?? 2]}
onValueChange={(values) => {
onChange({
digitRange: {
min: values[0],
max: values[1],
},
})
}}
min={1}
max={5}
step={1}
minStepsBetweenThumbs={0}
>
<Slider.Track
className={css({
position: 'relative',
flexGrow: 1,
bg: 'gray.200',
rounded: 'full',
height: '2',
})}
>
<Slider.Range
className={css({
position: 'absolute',
bg: 'brand.500',
rounded: 'full',
height: 'full',
})}
/>
</Slider.Track>
<Slider.Thumb
className={css({
display: 'block',
width: '4',
height: '4',
bg: 'white',
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
rounded: 'full',
border: '2px solid',
borderColor: 'brand.500',
cursor: 'grab',
transition: 'transform 0.15s',
_hover: { transform: 'scale(1.15)' },
_focus: { outline: 'none', boxShadow: '0 0 0 3px rgba(59, 130, 246, 0.3)' },
_active: { cursor: 'grabbing' },
})}
/>
<Slider.Thumb
className={css({
display: 'block',
width: '4',
height: '4',
bg: 'white',
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
rounded: 'full',
border: '2px solid',
borderColor: 'brand.600',
cursor: 'grab',
transition: 'transform 0.15s',
_hover: { transform: 'scale(1.15)' },
_focus: { outline: 'none', boxShadow: '0 0 0 3px rgba(59, 130, 246, 0.3)' },
_active: { cursor: 'grabbing' },
})}
/>
</Slider.Root>
</div>
</div>
{/* Mode Selector */}
<ModeSelector currentMode={formState.mode ?? 'smart'} onChange={handleModeChange} />
@ -1544,8 +1713,8 @@ export function ConfigPanel({ formState, onChange }: ConfigPanelProps) {
const y = clientY - rect.top
// Convert to difficulty space (0-10)
let regroupingIntensity = fromX(x)
let scaffoldingLevel = fromY(y)
const regroupingIntensity = fromX(x)
const scaffoldingLevel = fromY(y)
// Check if we're near a preset (within snap threshold)
const snapThreshold = 1.0 // 1.0 units in 0-10 scale
@ -1567,8 +1736,8 @@ export function ConfigPanel({ formState, onChange }: ConfigPanelProps) {
// Calculate Euclidean distance
const distance = Math.sqrt(
Math.pow(regroupingIntensity - presetReg, 2) +
Math.pow(scaffoldingLevel - presetScaf, 2)
(regroupingIntensity - presetReg) ** 2 +
(scaffoldingLevel - presetScaf) ** 2
)
if (distance <= snapThreshold) {