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 (
+
+
+
+ );
+}
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',
+ },
+};