Skip to content
Merged
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
1 change: 1 addition & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
@rules/aiworkers/skills-format.md
@rules/aiworkers/jsx-style.md
@rules/aiworkers/conventional-commits.md
@rules/component-patterns.md
38 changes: 38 additions & 0 deletions .claude/rules/component-patterns.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Component patterns

## Before implementing a new component

Always read an existing component of the same category before writing any code. Do not guess the structure — read the actual code.

- New Atom → read another existing Atom
- New Molecule → read another existing Molecule
- Component uses Reanimated → read the `useReanimatedStyles` hook of a component that already uses it (e.g. `CheckBox`)

This ensures hooks, file structure, test patterns, and import style are consistent with the rest of the library.

## Fixed values

No hardcoded numeric values in code. Always use design system tokens:

- Spacing and sizes: `Tokens.spacer({ key: '...' })`
- Border radii: `Tokens.radii({ key: '...' })`

Component-specific layout values (e.g. a Switch track width) belong in the component's `constants/` folder — one file per constant, all exported via `index.ts`:

```
constants/
├── NAME_OF_CONSTANT.ts ← one constant per file
└── index.ts ← export * from each file
```

Never define layout constants directly in `styles.ts` or in the component file.

## Storybook

Every new component needs a story at `example/.rnstorybook/stories/<Category>/<Component>/`.

Minimum variants to cover:
- Default state (off / false / empty)
- Active state (on / true / filled)
- Disabled (when applicable)
- `Interactive` — with `useState` to test the real animation in Storybook
57 changes: 57 additions & 0 deletions example/.rnstorybook/stories/Atoms/Switch/Switch.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { Meta, StoryObj } from '@storybook/react-native';
import React, { useState } from 'react';
import { Switch } from 'rubber-duck-ui';

const meta = {
title: 'Atoms/Switch',
component: Switch,
args: {
isOn: false,
onPress: () => {},
},
argTypes: {
isOn: { control: 'boolean' },
disabled: { control: 'boolean' },
},
} satisfies Meta<typeof Switch>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Default: Story = {};

export const On: Story = {
args: {
isOn: true,
},
};

export const Disabled: Story = {
args: {
disabled: true,
},
};

export const DisabledOn: Story = {
args: {
isOn: true,
disabled: true,
},
};

const InteractiveSwitch = (args: React.ComponentProps<typeof Switch>) => {
const [isOn, setIsOn] = useState(false);

return (
<Switch
{...args}
isOn={isOn}
onPress={() => setIsOn((prev) => !prev)}
/>
);
};

export const Interactive: Story = {
render: (args) => <InteractiveSwitch {...args} />,
};
44 changes: 44 additions & 0 deletions src/components/Atoms/Switch/__tests__/Switch.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { render, screen, fireEvent } from '@testing-library/react-native';
import { Switch } from '../index';

const baseProps = {
onPress: jest.fn(),
};

describe('Switch', () => {
beforeEach(() => {
jest.clearAllMocks();
});

describe('Rendering', () => {
it('Renders correctly when isOn is true', () => {
const { toJSON } = render(<Switch {...baseProps} isOn />);
expect(toJSON()).not.toBeNull();
});

it('Renders correctly when isOn is false', () => {
const { toJSON } = render(<Switch {...baseProps} isOn={false} />);
expect(toJSON()).not.toBeNull();
});
});

describe('Press behavior', () => {
it('Calls onPress when pressed', () => {
const onPress = jest.fn();
render(<Switch onPress={onPress} />);

fireEvent.press(screen.getByRole('switch'));

expect(onPress).toHaveBeenCalledTimes(1);
});

it('Does NOT call onPress when disabled is true', () => {
const onPress = jest.fn();
render(<Switch onPress={onPress} disabled />);

fireEvent.press(screen.getByRole('switch'));

expect(onPress).not.toHaveBeenCalled();
});
});
});
3 changes: 3 additions & 0 deletions src/components/Atoms/Switch/constants/SWITCH_PADDING.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { Tokens } from '../../../../tokens/Tokens.class';

export const SWITCH_PADDING = Tokens.spacer({ key: 'xxs' });
3 changes: 3 additions & 0 deletions src/components/Atoms/Switch/constants/SWITCH_THUMB_SIZE.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { Tokens } from '../../../../tokens/Tokens.class';

export const SWITCH_THUMB_SIZE = Tokens.spacer({ key: 'lg' });
3 changes: 3 additions & 0 deletions src/components/Atoms/Switch/constants/SWITCH_TRACK_HEIGHT.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { Tokens } from '../../../../tokens/Tokens.class';

export const SWITCH_TRACK_HEIGHT = Tokens.spacer({ key: 'xl' });
3 changes: 3 additions & 0 deletions src/components/Atoms/Switch/constants/SWITCH_TRACK_WIDTH.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { Tokens } from '../../../../tokens/Tokens.class';

export const SWITCH_TRACK_WIDTH = Tokens.spacer({ key: 'xxl' });
4 changes: 4 additions & 0 deletions src/components/Atoms/Switch/constants/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './SWITCH_TRACK_WIDTH';
export * from './SWITCH_TRACK_HEIGHT';
export * from './SWITCH_THUMB_SIZE';
export * from './SWITCH_PADDING';
2 changes: 2 additions & 0 deletions src/components/Atoms/Switch/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './useSwitchViewModel';
export * from './useReanimatedStyles';
32 changes: 32 additions & 0 deletions src/components/Atoms/Switch/hooks/useReanimatedStyles/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { IMountReanimatedStylesProps } from './types';
import { useEffect } from 'react';
import {
interpolateColor,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import { SWITCH_TRACK_WIDTH, SWITCH_THUMB_SIZE, SWITCH_PADDING } from '../../constants';

export const useReanimatedStyles = (props: IMountReanimatedStylesProps) => {
const { isOn, accentColor, borderDefaultColor } = props;

const progress = useSharedValue(isOn ? 1 : 0);

useEffect(() => {
progress.value = withTiming(isOn ? 1 : 0, { duration: 200 });
}, [isOn, progress]);

const track = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(progress.value, [0, 1], [borderDefaultColor, accentColor]),
}), [borderDefaultColor, accentColor]);

const thumb = useAnimatedStyle(() => ({
transform: [{ translateX: progress.value * (SWITCH_TRACK_WIDTH - SWITCH_THUMB_SIZE - 2 * SWITCH_PADDING) }],
}), []);

return {
track,
thumb,
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export type IMountReanimatedStylesProps = {
isOn: boolean;
accentColor: string;
borderDefaultColor: string;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './IMountReanimatedStylesProps';
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { renderHook } from '@testing-library/react-native';
import { useSwitchViewModel } from '../index';

describe('useSwitchViewModel', () => {
describe('onToggle', () => {
it('Calls onPress when not disabled', () => {
const onPress = jest.fn();
const { result } = renderHook(() => useSwitchViewModel({ onPress }));

result.current.onToggle();

expect(onPress).toHaveBeenCalledTimes(1);
});

it('Does NOT call onPress when disabled', () => {
const onPress = jest.fn();
const { result } = renderHook(() =>
useSwitchViewModel({ onPress, disabled: true }),
);

result.current.onToggle();

expect(onPress).not.toHaveBeenCalled();
});
});
});
15 changes: 15 additions & 0 deletions src/components/Atoms/Switch/hooks/useSwitchViewModel/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { ISwitchProps } from '../../types';

export const useSwitchViewModel = (props: ISwitchProps) => {
const { onPress, disabled } = props;

function onToggle() {
if (!disabled) {
onPress();
}
}

return {
onToggle,
};
};
36 changes: 36 additions & 0 deletions src/components/Atoms/Switch/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { ISwitchProps } from './types';
import React from 'react';
import { TouchableOpacity } from 'react-native';
import Animated from 'react-native-reanimated';
import { useStyles } from './styles';
import { SWITCH_PADDING } from './constants';
import { useRubberDuckStore } from '../../../store';
import { useSwitchViewModel, useReanimatedStyles } from './hooks';

export const Switch: React.FC<ISwitchProps> = (props) => {
const { isOn, disabled } = props;

const colors = useRubberDuckStore((s) => s.colors);
const styles = useStyles();
const reanimatedStyles = useReanimatedStyles({
isOn: !!isOn,
accentColor: colors.accent,
borderDefaultColor: colors.borderDefault,
});
const { onToggle } = useSwitchViewModel(props);

return (
<TouchableOpacity
activeOpacity={1}
onPress={onToggle}
disabled={!!disabled}
hitSlop={SWITCH_PADDING}
style={{ padding: SWITCH_PADDING }}
accessibilityRole="switch"
accessibilityState={{ checked: !!isOn, disabled: !!disabled }}>
<Animated.View style={[styles.track, reanimatedStyles.track]}>
<Animated.View style={[styles.thumb, reanimatedStyles.thumb]} />
</Animated.View>
</TouchableOpacity>
);
};
25 changes: 25 additions & 0 deletions src/components/Atoms/Switch/styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { StyleSheet } from 'react-native';
import { useRubberDuckStore } from '../../../store';
import { Tokens } from '../../../tokens/Tokens.class';
import { SWITCH_TRACK_WIDTH, SWITCH_TRACK_HEIGHT, SWITCH_THUMB_SIZE, SWITCH_PADDING } from './constants';

export const useStyles = () => {
const colors = useRubberDuckStore((s) => s.colors);

return StyleSheet.create({
track: {
width: SWITCH_TRACK_WIDTH,
height: SWITCH_TRACK_HEIGHT,
borderRadius: Tokens.radii({ key: 'full' }),
padding: SWITCH_PADDING,
justifyContent: 'center',
},

thumb: {
width: SWITCH_THUMB_SIZE,
height: SWITCH_THUMB_SIZE,
borderRadius: Tokens.radii({ key: 'full' }),
backgroundColor: colors.background,
},
});
};
5 changes: 5 additions & 0 deletions src/components/Atoms/Switch/types/ISwitchProps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export interface ISwitchProps {
isOn?: boolean;
onPress: () => void;
disabled?: boolean;
}
1 change: 1 addition & 0 deletions src/components/Atoms/Switch/types/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './ISwitchProps';
1 change: 1 addition & 0 deletions src/components/Atoms/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './Text';
export * from './Icon';
export * from './Loading';
export * from './Switch';
Loading