diff --git a/example/.ondevice/storybook.requires.js b/example/.ondevice/storybook.requires.js index af1f7fe6..26a6eafd 100644 --- a/example/.ondevice/storybook.requires.js +++ b/example/.ondevice/storybook.requires.js @@ -6,45 +6,45 @@ import { addParameters, addArgsEnhancer, clearDecorators, -} from '@storybook/react-native' +} from "@storybook/react-native"; global.STORIES = [ { - titlePrefix: '', - directory: './src', - files: '**/*.stories.?(ts|tsx|js|jsx)', + titlePrefix: "", + directory: "./src", + files: "**/*.stories.?(ts|tsx|js|jsx)", importPathMatcher: - '^\\.[\\\\/](?:src(?:\\/(?!\\.)(?:(?:(?!(?:^|\\/)\\.).)*?)\\/|\\/|$)(?!\\.)(?=.)[^/]*?\\.stories\\.(?:ts|tsx|js|jsx)?)$', + "^\\.[\\\\/](?:src(?:\\/(?!\\.)(?:(?:(?!(?:^|\\/)\\.).)*?)\\/|\\/|$)(?!\\.)(?=.)[^/]*?\\.stories\\.(?:ts|tsx|js|jsx)?)$", }, -] +]; -import '@storybook/addon-ondevice-notes/register' -import '@storybook/addon-ondevice-controls/register' -import '@storybook/addon-ondevice-backgrounds/register' -import '@storybook/addon-ondevice-actions/register' +import "@storybook/addon-ondevice-notes/register"; +import "@storybook/addon-ondevice-controls/register"; +import "@storybook/addon-ondevice-backgrounds/register"; +import "@storybook/addon-ondevice-actions/register"; -import {argsEnhancers} from '@storybook/addon-actions/dist/modern/preset/addArgs' +import { argsEnhancers } from "@storybook/addon-actions/dist/modern/preset/addArgs"; -import {decorators, parameters} from './preview' +import { decorators, parameters } from "./preview"; if (decorators) { if (__DEV__) { // stops the warning from showing on every HMR - require('react-native').LogBox.ignoreLogs([ - '`clearDecorators` is deprecated and will be removed in Storybook 7.0', - ]) + require("react-native").LogBox.ignoreLogs([ + "`clearDecorators` is deprecated and will be removed in Storybook 7.0", + ]); } // workaround for global decorators getting infinitely applied on HMR, see https://github.com/storybookjs/react-native/issues/185 - clearDecorators() - decorators.forEach(decorator => addDecorator(decorator)) + clearDecorators(); + decorators.forEach((decorator) => addDecorator(decorator)); } if (parameters) { - addParameters(parameters) + addParameters(parameters); } try { - argsEnhancers.forEach(enhancer => addArgsEnhancer(enhancer)) + argsEnhancers.forEach((enhancer) => addArgsEnhancer(enhancer)); } catch {} const getStories = () => { @@ -56,7 +56,8 @@ const getStories = () => { './src/stories/Progress.stories.tsx': require('../src/stories/Progress.stories.tsx'), './src/stories/RadioButton.stories.tsx': require('../src/stories/RadioButton.stories.tsx'), './src/stories/Text.stories.tsx': require('../src/stories/Text.stories.tsx'), + "./src/stories/Switch.stories.tsx": require("../src/stories/Switch.stories.tsx"), } -} +}; -configure(getStories, module, false) +configure(getStories, module, false); diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 7cf45634..2b519c86 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -689,4 +689,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 41b345e700f785c9a103f926104c4c507c89b32e -COCOAPODS: 1.11.3 +COCOAPODS: 1.12.1 diff --git a/example/src/App.tsx b/example/src/App.tsx index 7d7a7882..88ee7b81 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -5,6 +5,7 @@ import StorybookUIRoot from '../.ondevice/Storybook' const theme = extendTheme({ colors: { cardPrimaryBackground: 'green', + primary: 'grey', }, darkColors: { cardPrimaryBackground: 'gray', diff --git a/example/src/stories/Switch.stories.tsx b/example/src/stories/Switch.stories.tsx new file mode 100644 index 00000000..578577be --- /dev/null +++ b/example/src/stories/Switch.stories.tsx @@ -0,0 +1,54 @@ +/* eslint-disable react-native/no-inline-styles */ +import type {ComponentMeta, ComponentStory} from '@storybook/react' +import React, {useState} from 'react' + +import {View} from 'react-native' +import {Switch, useTheme} from 'rn-base-component' + +export default { + title: 'components/Switch', + component: Switch, +} as ComponentMeta + +export const Basic: ComponentStory = rest => { + const [isActive, setIsActive] = useState(false) + const theme = useTheme() + + const onValueChange = () => { + setIsActive(prev => !prev) + } + + return ( + + + + ) +} + +export const Inside: ComponentStory = rest => { + const [isActive, setIsActive] = useState(false) + const theme = useTheme() + + const onValueChange = () => { + setIsActive(prev => !prev) + } + + return ( + + + + ) +} diff --git a/example/src/stories/Text.stories.tsx b/example/src/stories/Text.stories.tsx index 3b7915ad..4281b523 100644 --- a/example/src/stories/Text.stories.tsx +++ b/example/src/stories/Text.stories.tsx @@ -1,12 +1,12 @@ -import React from 'react' import type {ComponentMeta, ComponentStory} from '@storybook/react' +import React from 'react' -import {Text, TextBold, TextItalic} from 'rn-base-component' import {StyleSheet, View} from 'react-native' +import {Text, TextBold, TextItalic} from 'rn-base-component' import {metrics} from '../../../src/helpers' export default { - title: 'Text', + title: 'components/Text', component: Text, } as ComponentMeta diff --git a/src/__tests__/switch.test.tsx b/src/__tests__/switch.test.tsx new file mode 100644 index 00000000..55932113 --- /dev/null +++ b/src/__tests__/switch.test.tsx @@ -0,0 +1,127 @@ +import {fireEvent, render} from '@testing-library/react-native' +import React from 'react' +import {Switch} from '../components' + +describe('Switch', () => { + const value = true + const onValueChange = jest.fn() + + it('renders without errors', () => { + const {getByTestId} = render() + const switchContainer = getByTestId('switch-container') + + expect(switchContainer).toBeDefined() + }) + + it('calls onValueChange when pressed', () => { + const {getByTestId} = render() + const switchContainer = getByTestId('switch-container') + + fireEvent.press(switchContainer) + expect(onValueChange).toHaveBeenCalled() + }) + + it('renders with the correct track color', () => { + const trackColor = 'red' + const {getByTestId} = render( + , + ) + const trackActive = getByTestId('track-active') + const trackInActive = getByTestId('track-in-active') + + expect(trackActive.props.style.backgroundColor).toBe(trackColor) + expect(trackInActive.props.style.backgroundColor).toBe(trackColor) + }) + + it('renders with the correct object track color', () => { + const trackColor = {active: 'red', inActive: 'grey'} + const {getByTestId} = render( + , + ) + const trackActive = getByTestId('track-active') + const trackInActive = getByTestId('track-in-active') + + expect(trackActive.props.style.backgroundColor).toBe(trackColor.active) + expect(trackInActive.props.style.backgroundColor).toBe(trackColor.inActive) + }) + + it('renders with the thembSize prop', () => { + const thumbSize = 20 + const {getByTestId} = render() + const trackActive = getByTestId('track-active') + const trackInActive = getByTestId('track-in-active') + const thumb = getByTestId('thumb') + + expect(trackActive.props.thumbSize).toBe(thumbSize) + expect(trackInActive.props.thumbSize).toBe(thumbSize) + expect(thumb.props.thumbSize).toBe(thumbSize) + }) + + it('renders with the thumbColor prop', () => { + const thumbColor = 'red' + const {getByTestId} = render( + , + ) + const thumb = getByTestId('thumb') + + expect(thumb.props.thumbColor).toBe(thumbColor) + }) + + it('disables the component when disabled prop is true', () => { + const {getByTestId} = render() + const switchContainer = getByTestId('switch-container') + + fireEvent.press(switchContainer) + expect(onValueChange).toHaveBeenCalled() + }) + + it('renders with the correct text inside for active and inactive states', () => { + const textInside = { + active: 'Active', + inActive: 'Inactive', + } + const {getByText} = render( + , + ) + const labelActive = getByText(textInside.active) + const labelInActive = getByText(textInside.inActive) + + expect(labelActive).toBeDefined() + expect(labelInActive).toBeDefined() + }) + + it('renders with custom text inside color', () => { + const textInsideColor = { + active: 'green', + inActive: 'red', + } + const {getByTestId} = render( + , + ) + const labelActive = getByTestId('label-active') + const labelInActive = getByTestId('label-in-active') + + expect(labelActive.props.color).toBe(textInsideColor.active) + expect(labelInActive.props.color).toBe(textInsideColor.inActive) + }) + + it('renders with the trackPaddingInside prop with variant inside', () => { + const trackPaddingInside = 4 + const {getByTestId} = render( + , + ) + const switchContainer = getByTestId('switch-container') + + expect(switchContainer.props.trackMargin).toBe(trackPaddingInside) + }) +}) diff --git a/src/components/Switch/Switch.tsx b/src/components/Switch/Switch.tsx new file mode 100644 index 00000000..ae5984f8 --- /dev/null +++ b/src/components/Switch/Switch.tsx @@ -0,0 +1,242 @@ +import React, {useMemo} from 'react' +import {Pressable, Text, View} from 'react-native' +import Animated, {useAnimatedStyle, withTiming} from 'react-native-reanimated' +import styled from 'styled-components/native' +import {theme as defaultTheme} from '../../theme' +import { + SPACING_INSIDE, + SPACING_OUTSIDE, + SwitchVariant, + TEXT_INSIDE, + TEXT_INSIDE_COLOR, + THUMB_SIZE, + TRACK_RADIUS, + TRACK_WIDTH, +} from './constants' + +export interface ITrackSize { + width: number + height: number +} + +export interface SwitchTheme { + active: string + inActive: string +} + +export interface SwitchProps { + /** + * current value of switch + * default: false + */ + value: boolean + + /** + * function call when value changes + */ + onValueChange: () => void + + /** + * track color for switch + * default: defaultTheme.colors.darkText: #27272a + */ + trackColor?: string | SwitchTheme + + /** + * thumb size of switch + * default: 30 + */ + thumbSize?: number + + /** + * thumb color of switch + * default: white + */ + thumbColor?: string | SwitchTheme + + /** + * variant to render if thumb outside or inside switch + * default: outside + */ + variant?: 'inside' | 'outside' + + /** + * disabled to change switch + * default: false + */ + disabled?: boolean + + /** + * props to show text in switch + * default: { + * active: 'On', + inActive: 'Off' + * } + */ + textInside?: SwitchTheme + /** + * props to show text with color in switch + * default: { + * active: 'white', + inActive: 'black', + * } + */ + textInsideColor?: SwitchTheme + + /** + * number for padding inside trach + * default: 3 + */ + trackPaddingInside?: number +} + +export interface ISwitchContainer { + trackSize: ITrackSize + trackMargin: number +} + +export interface IThumb { + thumbSize: number +} + +export interface ITrack { + trackSize: ITrackSize + thumbSize?: number +} + +export interface ILabel { + color: string +} + +const Switch: React.FC = ({ + value, + onValueChange, + trackColor = defaultTheme.colors.darkText, + thumbSize = THUMB_SIZE, + thumbColor = defaultTheme.colors.white, + variant = SwitchVariant.outside, + disabled, + textInside = TEXT_INSIDE, + textInsideColor = TEXT_INSIDE_COLOR, + trackPaddingInside = SPACING_INSIDE, +}) => { + const isThumbOutside = useMemo(() => variant === SwitchVariant.outside, [variant]) + const trackSize: ITrackSize = useMemo(() => { + const width = thumbSize * 2 + TRACK_WIDTH + let height = 0 + if (isThumbOutside) { + height = thumbSize - SPACING_OUTSIDE + } else { + height = thumbSize + trackPaddingInside * 2 + } + return {width, height} + }, [isThumbOutside, thumbSize, trackPaddingInside]) + const trackMargin = useMemo( + () => (isThumbOutside ? 0 : trackPaddingInside), + [isThumbOutside, trackPaddingInside], + ) + + // HANDLE ANIMATED STYLE + const thumbAnimatedStyle = useAnimatedStyle(() => { + const translateX = withTiming(value ? trackSize.width - thumbSize - trackMargin * 2 : 0) + const translateY = trackSize.height / 2 - thumbSize / 2 + const trackActiveColor = typeof thumbColor === 'string' ? thumbColor : thumbColor?.active + const trackInActiveColor = typeof thumbColor === 'string' ? thumbColor : thumbColor?.inActive + const backgroundColor = withTiming(value ? trackActiveColor : trackInActiveColor) + return { + transform: [{translateX}, {translateY}], + backgroundColor, + } + }, [value, trackSize, thumbSize, trackMargin, thumbColor]) + const trackActiveAnimatedStyle = useAnimatedStyle(() => { + const translateX = withTiming(value ? 0 : -trackSize.width) + const backgroundColor = typeof trackColor === 'string' ? trackColor : trackColor?.active + return { + transform: [{translateX}], + backgroundColor, + } + }, [value, trackSize, trackColor]) + const trackInActiveAnimatedStyle = useAnimatedStyle(() => { + const translateX = withTiming(value ? trackSize.width : 0) + const backgroundColor = typeof trackColor === 'string' ? trackColor : trackColor?.inActive + return { + transform: [{translateX}], + backgroundColor, + } + }, [value, trackSize, trackColor]) + + return ( + + + + {!isThumbOutside && ( + + )} + + + {!isThumbOutside && ( + + )} + + + + + ) +} + +const SwitchContainer = styled(Pressable)(({trackSize, trackMargin}) => ({ + width: trackSize.width, + height: trackSize.height, + borderRadius: TRACK_RADIUS, + paddingLeft: trackMargin, + paddingRight: trackMargin, +})) + +const Thumb = styled(Animated.View)(({thumbSize}) => ({ + width: thumbSize, + height: thumbSize, + borderRadius: thumbSize, +})) + +const TrackContainer = styled(View)(({trackSize}) => ({ + position: 'absolute', + width: trackSize.width, + height: trackSize.height, + borderRadius: TRACK_RADIUS, + overflow: 'hidden', +})) + +const TrackActive = styled(Animated.View)(({trackSize, thumbSize}) => ({ + position: 'absolute', + width: trackSize.width, + height: trackSize.height, + justifyContent: 'center', + alignItems: 'center', + paddingRight: thumbSize, +})) + +const TrackInActive = styled(Animated.View)(({trackSize, thumbSize}) => ({ + position: 'absolute', + width: trackSize.width, + height: trackSize.height, + justifyContent: 'center', + alignItems: 'center', + paddingLeft: thumbSize, +})) + +const Label = styled(Text)(({color}) => ({ + color, +})) + +export default Switch diff --git a/src/components/Switch/constants.ts b/src/components/Switch/constants.ts new file mode 100644 index 00000000..1a287031 --- /dev/null +++ b/src/components/Switch/constants.ts @@ -0,0 +1,20 @@ +import type {SwitchTheme} from './Switch' + +export const TRACK_RADIUS = 1000 +export const THUMB_SIZE = 30 +export const TRACK_PADDING = 3 +export const TRACK_WIDTH = 10 +export const SPACING_OUTSIDE = 10 +export const SPACING_INSIDE = 3 +export enum SwitchVariant { + inside = 'inside', + outside = 'outside', +} +export const TEXT_INSIDE: SwitchTheme = { + active: 'On', + inActive: 'Off', +} +export const TEXT_INSIDE_COLOR: SwitchTheme = { + active: 'white', + inActive: 'black', +} diff --git a/src/components/index.ts b/src/components/index.ts index 957253c9..fe178146 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,10 +1,10 @@ -import RadioButton from './RadioButton/RadioButton' import Button from './Button' +import RadioButton from './RadioButton/RadioButton' -import Progress from './Progress/Progress' import Checkbox from './Checkbox/Checkbox' import CodeInput from './CodeInput/CodeInput' import Card from './Card/Card' - -export {Button, CodeInput, Checkbox, Progress, RadioButton, Card} +import Progress from './Progress/Progress' +import Switch from './Switch/Switch' export * from './Text/Text' +export {Button, CodeInput, Checkbox, Progress, RadioButton, Card, Switch}