refactor(levels): convert to Radix UI Slider with abacus theme

Replaced custom HTML input slider with Radix UI Slider for better
accessibility and consistency with the rest of the application.

Key changes:
- Integrated @radix-ui/react-slider for proper a11y support
- Maintained abacus-themed visual design with bead ticks
- Larger interactive area (h: '12' / 48px) for easier interaction
- Color-coded beads (green for Kyu, violet for Dan)
- Interactive thumb styled as a prominent bead with grab cursor
- Decorative beads for all 40 levels with pointer-events: none
- Smooth transitions and hover effects on thumb
- Removed custom hover handler in favor of Radix's built-in interaction

This provides a more robust and accessible slider while maintaining
the unique abacus aesthetic.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-10-20 09:15:46 -05:00
parent f3dce84532
commit a03e73c849

View File

@@ -1,7 +1,8 @@
'use client'
import { useRef, useState } from 'react'
import { useState } from 'react'
import { useSpring, animated } from '@react-spring/web'
import * as Slider from '@radix-ui/react-slider'
import { AbacusReact } from '@soroban/abacus-react'
import { PageWithNav } from '@/components/PageWithNav'
import { css } from '../../../styled-system/css'
@@ -122,7 +123,6 @@ const allLevels = [
export default function LevelsPage() {
const [currentIndex, setCurrentIndex] = useState(0)
const sliderRef = useRef<HTMLInputElement>(null)
const currentLevel = allLevels[currentIndex]
// Calculate scale factor based on number of columns to fit the page
@@ -136,23 +136,6 @@ export default function LevelsPage() {
config: { tension: 280, friction: 60 },
})
// Handle mouse/touch move over slider for hover interaction
const handleSliderHover = (
e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>
) => {
if (!sliderRef.current) return
const rect = sliderRef.current.getBoundingClientRect()
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX
const x = clientX - rect.left
const percentage = Math.max(0, Math.min(1, x / rect.width))
const newIndex = Math.round(percentage * (allLevels.length - 1))
if (newIndex !== currentIndex) {
setCurrentIndex(newIndex)
}
}
// Generate an interesting non-zero number to display on the abacus
// Use a suffix pattern so rightmost digits stay constant as columns increase
// This prevents beads from shifting: ones always 9, tens always 8, etc.
@@ -300,94 +283,108 @@ export default function LevelsPage() {
)}
</div>
{/* Range Slider with hover support - Abacus themed */}
{/* Abacus-themed Radix Slider */}
<div className={css({ mb: '6', px: { base: '2', md: '8' } })}>
<div className={css({ mb: '3', textAlign: 'center' })}>
<p className={css({ fontSize: 'sm', color: 'gray.400' })}>
Hover, drag, or touch the beads to explore all levels
Drag or click the beads to explore all levels
</p>
</div>
<div
onMouseMove={handleSliderHover}
onTouchMove={handleSliderHover}
className={css({
position: 'relative',
py: '6',
cursor: 'pointer',
})}
>
{/* Reckoning Bar (Track) */}
<div
className={css({
position: 'absolute',
top: '50%',
left: 0,
right: 0,
h: '3px',
bg: 'rgba(255, 255, 255, 0.2)',
transform: 'translateY(-50%)',
rounded: 'full',
})}
/>
{/* Beads (Tick Marks) */}
{allLevels.map((level, index) => {
const isActive = index === currentIndex
const isDan = 'color' in level && level.color === 'violet'
const beadColor = isActive
? isDan
? 'violet.400'
: 'green.400'
: isDan
? 'rgba(139, 92, 246, 0.4)'
: 'rgba(34, 197, 94, 0.4)'
return (
<div
key={index}
className={css({
position: 'absolute',
top: '50%',
left: `${(index / (allLevels.length - 1)) * 100}%`,
transform: 'translate(-50%, -50%)',
w: isActive ? '16px' : '12px',
h: isActive ? '16px' : '12px',
bg: beadColor,
rounded: 'full',
border: '2px solid',
borderColor: isActive
? isDan
? 'violet.200'
: 'green.200'
: 'rgba(255, 255, 255, 0.3)',
transition: 'all 0.2s',
boxShadow: isActive
? `0 0 12px ${isDan ? 'rgba(139, 92, 246, 0.6)' : 'rgba(34, 197, 94, 0.6)'}`
: 'none',
})}
/>
)
})}
{/* Invisible input for accessibility and native slider behavior */}
<input
ref={sliderRef}
type="range"
min="0"
<div className={css({ position: 'relative', py: '6' })}>
<Slider.Root
value={[currentIndex]}
onValueChange={([value]) => setCurrentIndex(value)}
min={0}
max={allLevels.length - 1}
value={currentIndex}
onChange={(e) => setCurrentIndex(Number(e.target.value))}
step={1}
className={css({
position: 'absolute',
top: 0,
left: 0,
w: '100%',
h: '100%',
opacity: 0,
position: 'relative',
display: 'flex',
alignItems: 'center',
userSelect: 'none',
touchAction: 'none',
w: 'full',
h: '12',
cursor: 'pointer',
})}
/>
>
{/* Decorative beads for all levels */}
{allLevels.map((level, index) => {
const isActive = index === currentIndex
const isDan = 'color' in level && level.color === 'violet'
const beadColor = isActive
? isDan
? 'violet.400'
: 'green.400'
: isDan
? 'rgba(139, 92, 246, 0.4)'
: 'rgba(34, 197, 94, 0.4)'
return (
<div
key={index}
className={css({
position: 'absolute',
top: '50%',
left: `${(index / (allLevels.length - 1)) * 100}%`,
transform: 'translate(-50%, -50%)',
w: isActive ? '16px' : '10px',
h: isActive ? '16px' : '10px',
bg: beadColor,
rounded: 'full',
border: '2px solid',
borderColor: isActive
? isDan
? 'violet.200'
: 'green.200'
: 'rgba(255, 255, 255, 0.3)',
transition: 'all 0.2s',
boxShadow: isActive
? `0 0 12px ${isDan ? 'rgba(139, 92, 246, 0.6)' : 'rgba(34, 197, 94, 0.6)'}`
: 'none',
pointerEvents: 'none',
zIndex: 0,
})}
/>
)
})}
<Slider.Track
className={css({
bg: 'rgba(255, 255, 255, 0.2)',
position: 'relative',
flexGrow: 1,
rounded: 'full',
h: '3px',
})}
>
<Slider.Range className={css({ display: 'none' })} />
</Slider.Track>
<Slider.Thumb
className={css({
display: 'block',
w: '20px',
h: '20px',
bg: currentLevel.color === 'violet' ? 'violet.500' : 'green.500',
shadow: '0 0 16px rgba(0, 0, 0, 0.4)',
rounded: 'full',
border: '3px solid',
borderColor: currentLevel.color === 'violet' ? 'violet.200' : 'green.200',
cursor: 'grab',
transition: 'all 0.2s',
zIndex: 10,
_hover: { transform: 'scale(1.15)' },
_focus: {
outline: 'none',
transform: 'scale(1.15)',
boxShadow: `0 0 20px ${currentLevel.color === 'violet' ? 'rgba(139, 92, 246, 0.8)' : 'rgba(34, 197, 94, 0.8)'}`,
},
_active: { cursor: 'grabbing' },
})}
/>
</Slider.Root>
</div>
{/* Level Markers */}