Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
1 change: 1 addition & 0 deletions src/rmg-circular-slider/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './rmg-circular-slider';
120 changes: 120 additions & 0 deletions src/rmg-circular-slider/rmg-circular-slider.stories.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box>
<RmgCircularSlider defaultValue={value} onChange={setValue} />
<Text mt={2}>Current value: {value}°</Text>
</Box>
);
};

export const CustomRange = () => {
const [value, setValue] = useState(0);

return (
<Box>
<RmgCircularSlider defaultValue={value} min={0} max={100} onChange={setValue} />
<Text mt={2}>Current value: {value} (range: 0-100)</Text>
</Box>
);
};

export const CustomSnapStep = () => {
const [value, setValue] = useState(0);

return (
<Box>
<VStack align="start" spacing={4}>
<Text>Snap step: 30° (every 30 degrees)</Text>
<RmgCircularSlider defaultValue={value} snapStep={30} snapThreshold={3} onChange={setValue} />
<Text>Current value: {value}°</Text>
</VStack>
</Box>
);
};

export const DisabledValues = () => {
const [value, setValue] = useState(0);
const disabledValues = [90, 180, 270];

return (
<Box>
<VStack align="start" spacing={4}>
<Text>Disabled values: 90°, 180°, 270°</Text>
<RmgCircularSlider defaultValue={value} disabledValues={disabledValues} onChange={setValue} />
<Text>Current value: {value}°</Text>
</VStack>
</Box>
);
};

export const CustomSize = () => {
const [value1, setValue1] = useState(0);
const [value2, setValue2] = useState(45);
const [value3, setValue3] = useState(90);

return (
<HStack spacing={8}>
<VStack>
<RmgCircularSlider defaultValue={value1} size={80} onChange={setValue1} />
<Text>Size: 80px ({value1}°)</Text>
</VStack>
<VStack>
<RmgCircularSlider defaultValue={value2} size={120} onChange={setValue2} />
<Text>Size: 120px ({value2}°)</Text>
</VStack>
<VStack>
<RmgCircularSlider defaultValue={value3} size={160} onChange={setValue3} />
<Text>Size: 160px ({value3}°)</Text>
</VStack>
</HStack>
);
};

export const CustomStep = () => {
const [value, setValue] = useState(0);

return (
<Box>
<VStack align="start" spacing={4}>
<Text>Step: 5 (moves 5 degrees at a time with keyboard)</Text>
<RmgCircularSlider defaultValue={value} step={5} onChange={setValue} />
<Text>Current value: {value}°</Text>
</VStack>
</Box>
);
};

export const LargeWithAllFeatures = () => {
const [value, setValue] = useState(45);
const disabledValues = [180];

return (
<Box>
<VStack align="start" spacing={4}>
<Text>Large slider with 180° disabled</Text>
<RmgCircularSlider
defaultValue={value}
size={200}
disabledValues={disabledValues}
snapStep={45}
snapThreshold={3}
onChange={setValue}
/>
<Text>
Current value: {value}° {value % 45 === 0 ? '(snapped to 45° mark)' : ''}
</Text>
</VStack>
</Box>
);
};
195 changes: 195 additions & 0 deletions src/rmg-circular-slider/rmg-circular-slider.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<RmgCircularSlider />);

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(<RmgCircularSlider defaultValue={45} min={0} max={359} />);

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(
<RmgCircularSlider
defaultValue={50}
min={0}
max={359}
step={1}
snapStep={45}
snapThreshold={3}
{...mockCallbacks}
/>
);

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(
<RmgCircularSlider
defaultValue={50}
min={0}
max={359}
step={1}
snapStep={45}
snapThreshold={3}
{...mockCallbacks}
/>
);

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(
<RmgCircularSlider
defaultValue={50}
min={0}
max={359}
step={1}
snapStep={45}
snapThreshold={3}
{...mockCallbacks}
/>
);

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(
<RmgCircularSlider
defaultValue={50}
min={0}
max={359}
step={1}
snapStep={45}
snapThreshold={3}
{...mockCallbacks}
/>
);

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(<RmgCircularSlider defaultValue={359} min={0} max={359} step={1} {...mockCallbacks} />);

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(
<RmgCircularSlider
defaultValue={1}
min={0}
max={359}
step={1}
snapStep={45}
snapThreshold={3}
{...mockCallbacks}
/>
);

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(
<RmgCircularSlider
defaultValue={179}
min={0}
max={359}
step={1}
disabledValues={[180]}
{...mockCallbacks}
/>
);

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(
<RmgCircularSlider
defaultValue={43}
min={0}
max={359}
step={1}
snapStep={45}
snapThreshold={3}
{...mockCallbacks}
/>
);

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(<RmgCircularSlider size={150} />);

const slider = screen.getByRole('slider');
const svg = slider as SVGSVGElement;
expect(svg.getAttribute('width')).toBe('150');
expect(svg.getAttribute('height')).toBe('150');
});
});
Loading