Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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