diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index e7f7ae1..a815d2e 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -2,3 +2,4 @@ @rules/aiworkers/skills-format.md @rules/aiworkers/jsx-style.md @rules/aiworkers/conventional-commits.md +@rules/component-patterns.md diff --git a/.claude/rules/component-patterns.md b/.claude/rules/component-patterns.md new file mode 100644 index 0000000..8a7ddf4 --- /dev/null +++ b/.claude/rules/component-patterns.md @@ -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///`. + +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 diff --git a/example/.rnstorybook/stories/Atoms/Switch/Switch.stories.tsx b/example/.rnstorybook/stories/Atoms/Switch/Switch.stories.tsx new file mode 100644 index 0000000..01bafdc --- /dev/null +++ b/example/.rnstorybook/stories/Atoms/Switch/Switch.stories.tsx @@ -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; + +export default meta; + +type Story = StoryObj; + +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) => { + const [isOn, setIsOn] = useState(false); + + return ( + setIsOn((prev) => !prev)} + /> + ); +}; + +export const Interactive: Story = { + render: (args) => , +}; diff --git a/src/components/Atoms/Switch/__tests__/Switch.test.tsx b/src/components/Atoms/Switch/__tests__/Switch.test.tsx new file mode 100644 index 0000000..e82cfed --- /dev/null +++ b/src/components/Atoms/Switch/__tests__/Switch.test.tsx @@ -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(); + expect(toJSON()).not.toBeNull(); + }); + + it('Renders correctly when isOn is false', () => { + const { toJSON } = render(); + expect(toJSON()).not.toBeNull(); + }); + }); + + describe('Press behavior', () => { + it('Calls onPress when pressed', () => { + const onPress = jest.fn(); + render(); + + fireEvent.press(screen.getByRole('switch')); + + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('Does NOT call onPress when disabled is true', () => { + const onPress = jest.fn(); + render(); + + fireEvent.press(screen.getByRole('switch')); + + expect(onPress).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/components/Atoms/Switch/constants/SWITCH_PADDING.ts b/src/components/Atoms/Switch/constants/SWITCH_PADDING.ts new file mode 100644 index 0000000..f66ddb7 --- /dev/null +++ b/src/components/Atoms/Switch/constants/SWITCH_PADDING.ts @@ -0,0 +1,3 @@ +import { Tokens } from '../../../../tokens/Tokens.class'; + +export const SWITCH_PADDING = Tokens.spacer({ key: 'xxs' }); diff --git a/src/components/Atoms/Switch/constants/SWITCH_THUMB_SIZE.ts b/src/components/Atoms/Switch/constants/SWITCH_THUMB_SIZE.ts new file mode 100644 index 0000000..576d1eb --- /dev/null +++ b/src/components/Atoms/Switch/constants/SWITCH_THUMB_SIZE.ts @@ -0,0 +1,3 @@ +import { Tokens } from '../../../../tokens/Tokens.class'; + +export const SWITCH_THUMB_SIZE = Tokens.spacer({ key: 'lg' }); diff --git a/src/components/Atoms/Switch/constants/SWITCH_TRACK_HEIGHT.ts b/src/components/Atoms/Switch/constants/SWITCH_TRACK_HEIGHT.ts new file mode 100644 index 0000000..d99fcbc --- /dev/null +++ b/src/components/Atoms/Switch/constants/SWITCH_TRACK_HEIGHT.ts @@ -0,0 +1,3 @@ +import { Tokens } from '../../../../tokens/Tokens.class'; + +export const SWITCH_TRACK_HEIGHT = Tokens.spacer({ key: 'xl' }); diff --git a/src/components/Atoms/Switch/constants/SWITCH_TRACK_WIDTH.ts b/src/components/Atoms/Switch/constants/SWITCH_TRACK_WIDTH.ts new file mode 100644 index 0000000..854ca50 --- /dev/null +++ b/src/components/Atoms/Switch/constants/SWITCH_TRACK_WIDTH.ts @@ -0,0 +1,3 @@ +import { Tokens } from '../../../../tokens/Tokens.class'; + +export const SWITCH_TRACK_WIDTH = Tokens.spacer({ key: 'xxl' }); diff --git a/src/components/Atoms/Switch/constants/index.ts b/src/components/Atoms/Switch/constants/index.ts new file mode 100644 index 0000000..37203d5 --- /dev/null +++ b/src/components/Atoms/Switch/constants/index.ts @@ -0,0 +1,4 @@ +export * from './SWITCH_TRACK_WIDTH'; +export * from './SWITCH_TRACK_HEIGHT'; +export * from './SWITCH_THUMB_SIZE'; +export * from './SWITCH_PADDING'; diff --git a/src/components/Atoms/Switch/hooks/index.ts b/src/components/Atoms/Switch/hooks/index.ts new file mode 100644 index 0000000..416cc49 --- /dev/null +++ b/src/components/Atoms/Switch/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './useSwitchViewModel'; +export * from './useReanimatedStyles'; diff --git a/src/components/Atoms/Switch/hooks/useReanimatedStyles/index.ts b/src/components/Atoms/Switch/hooks/useReanimatedStyles/index.ts new file mode 100644 index 0000000..bf46d64 --- /dev/null +++ b/src/components/Atoms/Switch/hooks/useReanimatedStyles/index.ts @@ -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, + }; +}; diff --git a/src/components/Atoms/Switch/hooks/useReanimatedStyles/types/IMountReanimatedStylesProps.ts b/src/components/Atoms/Switch/hooks/useReanimatedStyles/types/IMountReanimatedStylesProps.ts new file mode 100644 index 0000000..b776861 --- /dev/null +++ b/src/components/Atoms/Switch/hooks/useReanimatedStyles/types/IMountReanimatedStylesProps.ts @@ -0,0 +1,5 @@ +export type IMountReanimatedStylesProps = { + isOn: boolean; + accentColor: string; + borderDefaultColor: string; +}; diff --git a/src/components/Atoms/Switch/hooks/useReanimatedStyles/types/index.ts b/src/components/Atoms/Switch/hooks/useReanimatedStyles/types/index.ts new file mode 100644 index 0000000..eca5fcd --- /dev/null +++ b/src/components/Atoms/Switch/hooks/useReanimatedStyles/types/index.ts @@ -0,0 +1 @@ +export * from './IMountReanimatedStylesProps'; diff --git a/src/components/Atoms/Switch/hooks/useSwitchViewModel/__tests__/useSwitchViewModel.test.ts b/src/components/Atoms/Switch/hooks/useSwitchViewModel/__tests__/useSwitchViewModel.test.ts new file mode 100644 index 0000000..3e9b7e9 --- /dev/null +++ b/src/components/Atoms/Switch/hooks/useSwitchViewModel/__tests__/useSwitchViewModel.test.ts @@ -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(); + }); + }); +}); diff --git a/src/components/Atoms/Switch/hooks/useSwitchViewModel/index.ts b/src/components/Atoms/Switch/hooks/useSwitchViewModel/index.ts new file mode 100644 index 0000000..e345eba --- /dev/null +++ b/src/components/Atoms/Switch/hooks/useSwitchViewModel/index.ts @@ -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, + }; +}; diff --git a/src/components/Atoms/Switch/index.tsx b/src/components/Atoms/Switch/index.tsx new file mode 100644 index 0000000..69a665d --- /dev/null +++ b/src/components/Atoms/Switch/index.tsx @@ -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 = (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 ( + + + + + + ); +}; diff --git a/src/components/Atoms/Switch/styles.ts b/src/components/Atoms/Switch/styles.ts new file mode 100644 index 0000000..a09d9e9 --- /dev/null +++ b/src/components/Atoms/Switch/styles.ts @@ -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, + }, + }); +}; diff --git a/src/components/Atoms/Switch/types/ISwitchProps.ts b/src/components/Atoms/Switch/types/ISwitchProps.ts new file mode 100644 index 0000000..7f3cfc0 --- /dev/null +++ b/src/components/Atoms/Switch/types/ISwitchProps.ts @@ -0,0 +1,5 @@ +export interface ISwitchProps { + isOn?: boolean; + onPress: () => void; + disabled?: boolean; +} diff --git a/src/components/Atoms/Switch/types/index.ts b/src/components/Atoms/Switch/types/index.ts new file mode 100644 index 0000000..19f62c9 --- /dev/null +++ b/src/components/Atoms/Switch/types/index.ts @@ -0,0 +1 @@ +export * from './ISwitchProps'; diff --git a/src/components/Atoms/index.ts b/src/components/Atoms/index.ts index af6e204..54b60f4 100644 --- a/src/components/Atoms/index.ts +++ b/src/components/Atoms/index.ts @@ -1,3 +1,4 @@ export * from './Text'; export * from './Icon'; export * from './Loading'; +export * from './Switch';