diff --git a/package-lock.json b/package-lock.json index 8f8db1d..314c8f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,7 +52,7 @@ }, "peerDependencies": { "@chakra-ui/react": "2.x", - "@railmapgen/rmg-runtime": ">=8 <11", + "@railmapgen/rmg-runtime": ">=8", "ag-grid-react": ">=28.1.0", "react": ">=18" } diff --git a/src/index.ts b/src/index.ts index c0c97ab..f89814d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ export * from './rmg-app-clip'; export * from './rmg-auto-complete'; export * from './rmg-button-group'; export * from './rmg-card'; +export * from './rmg-circular-slider'; export * from './rmg-data-table'; export * from './rmg-debounced-input'; export * from './rmg-debounced-textarea'; diff --git a/src/rmg-circular-slider/index.ts b/src/rmg-circular-slider/index.ts new file mode 100644 index 0000000..8a31045 --- /dev/null +++ b/src/rmg-circular-slider/index.ts @@ -0,0 +1 @@ +export * from './rmg-circular-slider'; diff --git a/src/rmg-circular-slider/rmg-circular-slider.stories.tsx b/src/rmg-circular-slider/rmg-circular-slider.stories.tsx new file mode 100644 index 0000000..166f754 --- /dev/null +++ b/src/rmg-circular-slider/rmg-circular-slider.stories.tsx @@ -0,0 +1,120 @@ +import { RmgCircularSlider } from './rmg-circular-slider'; +import { Box, Text, VStack, HStack } from '@chakra-ui/react'; +import { useState } from 'react'; + +export default { + title: 'RmgCircularSlider', + component: RmgCircularSlider, +}; + +export const Basic = () => { + const [value, setValue] = useState(0); + + return ( + + + Current value: {value}° + + ); +}; + +export const CustomRange = () => { + const [value, setValue] = useState(0); + + return ( + + + Current value: {value} (range: 0-100) + + ); +}; + +export const CustomSnapStep = () => { + const [value, setValue] = useState(0); + + return ( + + + Snap step: 30° (every 30 degrees) + + Current value: {value}° + + + ); +}; + +export const DisabledValues = () => { + const [value, setValue] = useState(0); + const disabledValues = [90, 180, 270]; + + return ( + + + Disabled values: 90°, 180°, 270° + + Current value: {value}° + + + ); +}; + +export const CustomSize = () => { + const [value1, setValue1] = useState(0); + const [value2, setValue2] = useState(45); + const [value3, setValue3] = useState(90); + + return ( + + + + Size: 80px ({value1}°) + + + + Size: 120px ({value2}°) + + + + Size: 160px ({value3}°) + + + ); +}; + +export const CustomStep = () => { + const [value, setValue] = useState(0); + + return ( + + + Step: 5 (moves 5 degrees at a time with keyboard) + + Current value: {value}° + + + ); +}; + +export const LargeWithAllFeatures = () => { + const [value, setValue] = useState(45); + const disabledValues = [180]; + + return ( + + + Large slider with 180° disabled + + + Current value: {value}° {value % 45 === 0 ? '(snapped to 45° mark)' : ''} + + + + ); +}; diff --git a/src/rmg-circular-slider/rmg-circular-slider.test.tsx b/src/rmg-circular-slider/rmg-circular-slider.test.tsx new file mode 100644 index 0000000..7a9c478 --- /dev/null +++ b/src/rmg-circular-slider/rmg-circular-slider.test.tsx @@ -0,0 +1,195 @@ +import '../polyfills'; +import { render } from '../test-utils'; +import { RmgCircularSlider } from './rmg-circular-slider'; +import { fireEvent, screen } from '@testing-library/react'; +import { vi } from 'vitest'; + +const mockCallbacks = { + onChange: vi.fn(), +}; + +describe('RmgCircularSlider', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('Can render circular slider with default props', () => { + render(); + + const slider = screen.getByRole('slider'); + expect(slider).toBeInTheDocument(); + expect(slider).toHaveAttribute('aria-valuemin', '0'); + expect(slider).toHaveAttribute('aria-valuemax', '359'); + expect(slider).toHaveAttribute('aria-valuenow', '0'); + }); + + it('Can render circular slider with custom range', () => { + render(); + + const slider = screen.getByRole('slider'); + expect(slider).toHaveAttribute('aria-valuenow', '45'); + }); + + it('Can handle keyboard navigation with ArrowRight', () => { + // Use value 50 which is not near a snap point (45 or 90) + render( + + ); + + const slider = screen.getByRole('slider'); + + // Move from 50 to 51 (not near snap point) + fireEvent.keyDown(slider, { key: 'ArrowRight' }); + expect(mockCallbacks.onChange).toBeCalledTimes(1); + expect(mockCallbacks.onChange).toBeCalledWith(51); + }); + + it('Can handle keyboard navigation with ArrowUp', () => { + render( + + ); + + const slider = screen.getByRole('slider'); + + fireEvent.keyDown(slider, { key: 'ArrowUp' }); + expect(mockCallbacks.onChange).toBeCalledTimes(1); + expect(mockCallbacks.onChange).toBeCalledWith(51); + }); + + it('Can handle keyboard navigation with ArrowLeft', () => { + render( + + ); + + const slider = screen.getByRole('slider'); + + fireEvent.keyDown(slider, { key: 'ArrowLeft' }); + expect(mockCallbacks.onChange).toBeCalledTimes(1); + expect(mockCallbacks.onChange).toBeCalledWith(49); + }); + + it('Can handle keyboard navigation with ArrowDown', () => { + render( + + ); + + const slider = screen.getByRole('slider'); + + fireEvent.keyDown(slider, { key: 'ArrowDown' }); + expect(mockCallbacks.onChange).toBeCalledTimes(1); + expect(mockCallbacks.onChange).toBeCalledWith(49); + }); + + it('Can wrap around from max to min', () => { + render(); + + const slider = screen.getByRole('slider'); + + fireEvent.keyDown(slider, { key: 'ArrowRight' }); + expect(mockCallbacks.onChange).toBeCalledWith(0); + }); + + it('Can wrap around from min to max', () => { + // Start at 4, not 0, because 0 is a snap point + // Moving from 4 down by 1 goes to 3, then 2, then 1, then 0 snaps to 0 + // We need to disable snapping or start from a position that won't snap + render( + + ); + + const slider = screen.getByRole('slider'); + + // 1 -> 0 (within snap threshold, snaps to 0) + fireEvent.keyDown(slider, { key: 'ArrowLeft' }); + expect(mockCallbacks.onChange).toBeCalledWith(0); + }); + + it('Should not allow selecting disabled values', () => { + render( + + ); + + const slider = screen.getByRole('slider'); + + // Try to move to disabled value + fireEvent.keyDown(slider, { key: 'ArrowRight' }); + // Should not call onChange when disabled value is encountered + expect(mockCallbacks.onChange).not.toBeCalled(); + }); + + it('Can snap to step values within threshold', () => { + render( + + ); + + const slider = screen.getByRole('slider'); + + // Move from 43 to 44, should snap to 45 (within threshold) + fireEvent.keyDown(slider, { key: 'ArrowRight' }); + expect(mockCallbacks.onChange).toBeCalledWith(45); + }); + + it('Should use custom size', () => { + render(); + + const slider = screen.getByRole('slider'); + const svg = slider as SVGSVGElement; + expect(svg.getAttribute('width')).toBe('150'); + expect(svg.getAttribute('height')).toBe('150'); + }); +}); diff --git a/src/rmg-circular-slider/rmg-circular-slider.tsx b/src/rmg-circular-slider/rmg-circular-slider.tsx new file mode 100644 index 0000000..2140c95 --- /dev/null +++ b/src/rmg-circular-slider/rmg-circular-slider.tsx @@ -0,0 +1,352 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Box, useColorModeValue, useStyleConfig, useToken } from '@chakra-ui/react'; + +export interface RmgCircularSliderProps { + /** + * Current value of the slider + */ + defaultValue?: number; + /** + * Minimum value (default: 0) + */ + min?: number; + /** + * Maximum value (default: 359) + */ + max?: number; + /** + * Step size for value changes (default: 1) + */ + step?: number; + /** + * Snap alignment step (default: 45). Only snaps when within snapThreshold degrees. + */ + snapStep?: number; + /** + * Threshold for snap alignment in degrees (default: 3) + */ + snapThreshold?: number; + /** + * Array of disabled values + */ + disabledValues?: number[]; + /** + * Callback when value changes + */ + onChange?: (value: number) => void; + /** + * Size of the circular slider in pixels (default: 100) + */ + size?: number; +} + +// Convert value to angle (0 at top, clockwise) +// 0 degrees = top (12 o'clock position) +const valueToAngle = (value: number, min: number, max: number): number => { + const range = max - min + 1; + const normalizedValue = (((value - min) % range) + range) % range; + return (normalizedValue / range) * 360; +}; + +// Convert angle to value +const angleToValue = (angle: number, min: number, max: number, step: number): number => { + const range = max - min + 1; + // Normalize angle to 0-360 + const normalizedAngle = ((angle % 360) + 360) % 360; + let value = (normalizedAngle / 360) * range + min; + // Round to step + value = Math.round(value / step) * step; + // Clamp to range + if (value > max) value = min; + if (value < min) value = min; + return value; +}; + +// Calculate angle from center to point (0 at top, clockwise) +const getAngleFromCenter = (centerX: number, centerY: number, x: number, y: number): number => { + const dx = x - centerX; + const dy = y - centerY; + // atan2 gives angle from positive x-axis, counter-clockwise + // We need angle from negative y-axis (top), clockwise + let angle = Math.atan2(dx, -dy) * (180 / Math.PI); + if (angle < 0) angle += 360; + return angle; +}; + +// Convert angle (0 at top, clockwise) to radians for SVG positioning +const angleToRadians = (angle: number): number => { + // Convert to standard math angle (0 at right, counter-clockwise) + // then convert to radians + // angle 0 (top) -> -90 degrees in standard math + // angle 90 (right) -> 0 degrees in standard math + return ((angle - 90) * Math.PI) / 180; +}; + +// Check if value should snap to a step +const getSnappedValue = (value: number, min: number, max: number, snapStep: number, snapThreshold: number): number => { + const range = max - min + 1; + const valueAngle = valueToAngle(value, min, max); + + // Check each snap point + for (let snapAngle = 0; snapAngle < 360; snapAngle += (snapStep / range) * 360) { + let diff = Math.abs(valueAngle - snapAngle); + // Handle wrap-around + if (diff > 180) diff = 360 - diff; + + if (diff <= snapThreshold) { + // Snap to this angle + return angleToValue(snapAngle, min, max, 1); + } + } + + return value; +}; + +export function RmgCircularSlider(props: RmgCircularSliderProps) { + const { + defaultValue = 0, + min = 0, + max = 359, + step = 1, + snapStep = 45, + snapThreshold = 3, + disabledValues = [], + onChange, + size = 100, + } = props; + + const styles = useStyleConfig('RmgCircularSlider'); + const [value, setValue] = useState(defaultValue); + const [isDragging, setIsDragging] = useState(false); + const svgRef = useRef(null); + + // Get color tokens and resolve to actual values + const trackColorToken = useColorModeValue('gray.200', 'gray.600'); + const pointerColorToken = useColorModeValue('blue.500', 'blue.300'); + const snapTickColorToken = useColorModeValue('red.400', 'red.300'); + const disabledTickColorToken = useColorModeValue('gray.400', 'gray.400'); + + const [trackColor, pointerColor, snapTickColor, disabledTickColor] = useToken('colors', [ + trackColorToken, + pointerColorToken, + snapTickColorToken, + disabledTickColorToken, + ]); + + const centerX = size / 2; + const centerY = size / 2; + const radius = size / 2 - 15; // Leave more margin for outer ticks + const trackWidth = 8; // Wider track + const innerRadius = radius / 2; // Pointer starts from r/2 + const tickOuterRadius = radius + 8; // Ticks only on outer ring + const tickInnerRadius = radius + 2; + + useEffect(() => { + if (defaultValue !== undefined && value !== defaultValue) { + setValue(defaultValue); + } + }, [defaultValue]); + + const updateValue = useCallback( + (clientX: number, clientY: number) => { + if (!svgRef.current) return; + + const rect = svgRef.current.getBoundingClientRect(); + const x = clientX - rect.left; + const y = clientY - rect.top; + + const angle = getAngleFromCenter(centerX, centerY, x, y); + let newValue = angleToValue(angle, min, max, step); + + // Apply snap + newValue = getSnappedValue(newValue, min, max, snapStep, snapThreshold); + + // Check if disabled + if (disabledValues.includes(newValue)) { + return; + } + + setValue(newValue); + onChange?.(newValue); + }, + [centerX, centerY, min, max, step, snapStep, snapThreshold, disabledValues, onChange] + ); + + const handleMouseDown = (e: React.MouseEvent) => { + setIsDragging(true); + updateValue(e.clientX, e.clientY); + }; + + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (isDragging) { + updateValue(e.clientX, e.clientY); + } + }, + [isDragging, updateValue] + ); + + const handleMouseUp = useCallback(() => { + setIsDragging(false); + }, []); + + const handleTouchStart = (e: React.TouchEvent) => { + setIsDragging(true); + const touch = e.touches[0]; + updateValue(touch.clientX, touch.clientY); + }; + + const handleTouchMove = useCallback( + (e: TouchEvent) => { + if (isDragging) { + const touch = e.touches[0]; + updateValue(touch.clientX, touch.clientY); + } + }, + [isDragging, updateValue] + ); + + useEffect(() => { + if (isDragging) { + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + window.addEventListener('touchmove', handleTouchMove); + window.addEventListener('touchend', handleMouseUp); + + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + window.removeEventListener('touchmove', handleTouchMove); + window.removeEventListener('touchend', handleMouseUp); + }; + } + }, [isDragging, handleMouseMove, handleMouseUp, handleTouchMove]); + + // Calculate pointer position (from r/2 to r) + const pointerAngle = valueToAngle(value, min, max); + const pointerRadians = angleToRadians(pointerAngle); + const pointerOuterX = centerX + radius * Math.cos(pointerRadians); + const pointerOuterY = centerY + radius * Math.sin(pointerRadians); + const pointerInnerX = centerX + innerRadius * Math.cos(pointerRadians); + const pointerInnerY = centerY + innerRadius * Math.sin(pointerRadians); + + // Generate tick marks (only on outer ring, light red color) + const range = max - min + 1; + const ticks = []; + + // Light tick color token + const lightTickColorToken = useColorModeValue('red.200', 'red.700'); + const [lightTickColor] = useToken('colors', [lightTickColorToken]); + + // Add snap step ticks (major ticks) - only on outer ring + for (let i = 0; i < range; i += snapStep) { + const tickValue = min + i; + const tickAngle = valueToAngle(tickValue, min, max); + const tickRadians = angleToRadians(tickAngle); + + const outerX = centerX + tickOuterRadius * Math.cos(tickRadians); + const outerY = centerY + tickOuterRadius * Math.sin(tickRadians); + const innerX = centerX + tickInnerRadius * Math.cos(tickRadians); + const innerY = centerY + tickInnerRadius * Math.sin(tickRadians); + + const isDisabled = disabledValues.includes(tickValue); + + ticks.push( + + ); + } + + // Check if current value is snapped + const isSnappedToStep = value % snapStep === 0 && !disabledValues.includes(value); + + // Text color for center value + const textColorToken = useColorModeValue('gray.600', 'gray.300'); + const [textColor] = useToken('colors', [textColorToken]); + + // Font size based on component size + const fontSize = Math.max(10, size / 8); + + return ( + + { + let newValue = value; + if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { + newValue = value + step; + if (newValue > max) newValue = min; + } else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { + newValue = value - step; + if (newValue < min) newValue = max; + } + + // Apply snap + newValue = getSnappedValue(newValue, min, max, snapStep, snapThreshold); + + // Check if disabled + if (!disabledValues.includes(newValue) && newValue !== value) { + setValue(newValue); + onChange?.(newValue); + } + }} + > + {/* Track circle - wider stroke */} + + + {/* Tick marks - outer ring only */} + {ticks} + + {/* Pointer line - from r/2 to r */} + + + {/* Pointer circle (knob) at outer end */} + + + {/* Center value text */} + + {value} + + + + ); +} diff --git a/src/theme/components/index.ts b/src/theme/components/index.ts index 90decf2..fd939d7 100644 --- a/src/theme/components/index.ts +++ b/src/theme/components/index.ts @@ -9,6 +9,7 @@ import { switchTheme as Switch } from './switch'; import { rmgAgGridTheme as RmgAgGrid } from './rmg-ag-grid'; import { rmgAppClipTheme as RmgAppClip } from './rmg-app-clip'; import { rmgCardTheme as RmgCard } from './rmg-card'; +import { rmgCircularSliderTheme as RmgCircularSlider } from './rmg-circular-slider'; import { rmgDataTableTheme as RmgDataTable } from './rmg-data-table'; import { rmgEnrichedButtonTheme as RmgEnrichedButton } from './rmg-enriched-button'; import { rmgErrorBoundaryTheme as RmgErrorBoundary } from './rmg-error-boundary'; @@ -37,6 +38,7 @@ export const components: Record = { RmgAgGrid, RmgAppClip, RmgCard, + RmgCircularSlider, RmgDataTable, RmgEnrichedButton, RmgErrorBoundary, diff --git a/src/theme/components/rmg-circular-slider.ts b/src/theme/components/rmg-circular-slider.ts new file mode 100644 index 0000000..b97273e --- /dev/null +++ b/src/theme/components/rmg-circular-slider.ts @@ -0,0 +1,9 @@ +import { ComponentStyleConfig } from '@chakra-ui/theme'; + +export const rmgCircularSliderTheme: ComponentStyleConfig = { + baseStyle: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + }, +};