diff --git a/docs/docs/components/radioButton.md b/docs/docs/components/radioButton.md new file mode 100644 index 0000000..a2c921c --- /dev/null +++ b/docs/docs/components/radioButton.md @@ -0,0 +1,121 @@ +import {RadioButton, HStack} from '@amalgama/react-native-ui-kit' +import {UIKitIcon} from '@amalgama/react-native-ui-kit/' +import CodePreview from '@site/src/components/CodePreview' +import ExampleRadioButton from '@site/src/components/ExampleRadioButton' +import {useState} from 'react' + +# RadioButton + +## Import + +To add the `RadioButton` component to your project you can import it as follows: + +```tsx +import {RadioButton} from '@amalgama/react-native-ui-kit'; +``` + +## Examples + + + + +## Props + +### as +The icon components family to use. See [Icon - as prop documentation](./icon.md#as) for more information. +The default value is `UIKitIcon`. + +```jsx +import UIKitIcon from '@amalgama/react-native-ui-kit'; + + { window && window.alert( 'Clicked!' );}} +/> +``` + +### selectedIcon +The name of the icon from the icon family selected to use when the button is selected. See [Icon - name prop documentation](./icon.md#name) for more information. The default value is `circle-filled`. + +```jsx +import UIKitIcon from '@amalgama/react-native-ui-kit'; + + { window && window.alert( 'Clicked!' );}} +/> +``` + +### unselectedIcon + +The name of the icon from the icon family selected to use when the button is unselected. See [Icon - name prop documentation](./icon.md#name) for more information. The default value is `circle`. + +```jsx +import UIKitIcon from '@amalgama/react-native-ui-kit'; + + { window && window.alert( 'Clicked!' );}} +/> +``` +### selected +If the RadioButton is selected or not. + +| TYPE | REQUIRED | DEFAULT | +| ---- | -------- | ------- | +| bool | No | false | + + + + + + + + +### disabled +If the radioButton is disabled or not. + +| TYPE | REQUIRED | DEFAULT | +| ---- | -------- | ------- | +| bool | No | false | + + + + + + + + + +### onPress +Invoked when the RadioButton is pressed. + +| TYPE | REQUIRED | +| -------- | -------- | +| function | No | + + + { window.alert( 'The RadioButton was pressed!' ) } }/> + + +```jsx + { window.alert( 'The radioButton was pressed!' ) } }/> +``` + +## Accessibility props +[React Native accessibility docs](https://reactnative.dev/docs/accessibility) + +### accessible +Sets the component to an accessibility element. It is set by default to `true`. + +### accessibilityRole +Communicates the purpose of the component. It is set by default to `"radio"`. + +### accessibilityState +Describes the current state of the element. By default, indicates if the `RadioButton` is `disabled`, `selected` or `unselected`. \ No newline at end of file diff --git a/docs/src/components/ExampleRadioButton/index.tsx b/docs/src/components/ExampleRadioButton/index.tsx new file mode 100644 index 0000000..5d14295 --- /dev/null +++ b/docs/src/components/ExampleRadioButton/index.tsx @@ -0,0 +1,14 @@ +import { RadioButton } from '@amalgama/react-native-ui-kit'; +import React, { useState } from 'react'; + +const ExampleRadioButton = () => { + const [ isSelected, setSelected ] = useState( false ); + return ( + { setSelected( prev => !prev ); } } + /> + ); +}; + +export default ExampleRadioButton; diff --git a/example/src/App.tsx b/example/src/App.tsx index bdd8e45..b607fa5 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -5,6 +5,7 @@ import { ThemeProvider, VStack, extendThemeConfig } from '@amalgama/react-native import FontAwesomeIcon from 'react-native-vector-icons/FontAwesome'; import IoniconsIcon from 'react-native-vector-icons/Ionicons'; +import RadioButtonExamples from './components/RadioButtonExamples'; import TextExamples from './components/TextExamples'; import BoxExamples from './components/BoxExamples'; import ButtonExamples from './components/ButtonExamples'; @@ -87,6 +88,7 @@ export default function App() { + diff --git a/example/src/components/RadioButtonExamples.tsx b/example/src/components/RadioButtonExamples.tsx new file mode 100644 index 0000000..79a0345 --- /dev/null +++ b/example/src/components/RadioButtonExamples.tsx @@ -0,0 +1,64 @@ +/* eslint-disable no-alert */ +import * as React from 'react'; + +import { StyleSheet, View } from 'react-native'; +import { + RadioButton, VStack, Text, HStack +} from '@amalgama/react-native-ui-kit'; + +const styles = StyleSheet.create( { + container: { + width: '100%', + marginBottom: 20 + }, + separator: { + height: 1, + minWidth: '100%', + marginTop: 2, + marginBottom: 6, + backgroundColor: 'black' + }, + vspace: { + height: 10, + minWidth: '100%' + } +} ); + +const RadioButtonExamples = () => { + const [ selected, setSelected ] = React.useState( false ); + return ( + + RadioButton Component + + Enabled + + + + + + + + + Disabled + + + + + + + + + On press + + + + setSelected( prev => !prev )} /> + + + + + + ); +}; + +export default RadioButtonExamples; diff --git a/src/components/hooks/index.tsx b/src/components/hooks/index.tsx new file mode 100644 index 0000000..6933a30 --- /dev/null +++ b/src/components/hooks/index.tsx @@ -0,0 +1,3 @@ +export { default as useIsFocused } from './useIsFocused'; +export { default as useIsHovered } from './useIsHovered'; +export { default as useIsPressed } from './useIsPressed'; diff --git a/src/components/index.ts b/src/components/index.ts index 60408fe..d0fb58b 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -6,3 +6,4 @@ export { default as Text } from './main/Text'; export { default as Icon } from './main/Icon'; export { default as IconButton } from './main/IconButton'; export { default as Checkbox } from './main/Checkbox'; +export { default as RadioButton } from './main/RadioButton'; diff --git a/src/components/main/IconButton/index.tsx b/src/components/main/IconButton/index.tsx index 68d6a62..a87b651 100644 --- a/src/components/main/IconButton/index.tsx +++ b/src/components/main/IconButton/index.tsx @@ -1,16 +1,17 @@ -import React from 'react'; +import React, { memo, forwardRef } from 'react'; import Pressable from '../Pressable'; import Icon from '../Icon'; import { useIconButtonPropsResolver } from './hooks'; import type { IIconButtonProps } from './types'; -const IconButton = ( { name, ...props }: IIconButtonProps ) => { +const IconButton = ( { name, ...props }: IIconButtonProps, ref?:any ) => { const { iconProps, containerProps } = useIconButtonPropsResolver( props ); return ( { ); }; -export default IconButton; +export default memo( forwardRef( IconButton ) ); diff --git a/src/components/main/RadioButton/hooks.ts b/src/components/main/RadioButton/hooks.ts new file mode 100644 index 0000000..236ec50 --- /dev/null +++ b/src/components/main/RadioButton/hooks.ts @@ -0,0 +1,54 @@ +import { useMemo } from 'react'; +import { useComponentPropsResolver } from '../../../hooks'; +import type { IRadioButtonProps } from './types'; +import { useIsFocused, useIsHovered, useIsPressed } from '../../hooks'; +import type { IIconButtonProps } from '../IconButton/types'; + +interface IUseRadioButtonPropsResolverReturnType { + iconProps: IRadioButtonProps[ '__icon' ]; + restProps: Omit; +} + +const useRadioButtonPropsResolver = ( + { + selectedIcon, + unselectedIcon, + ...props + } : IRadioButtonProps ) : IUseRadioButtonPropsResolverReturnType => { + const { isPressed, onPressIn, onPressOut } = useIsPressed( props ); + const { isHovered, onHoverIn, onHoverOut } = useIsHovered( props ); + const { isFocused, onFocus, onBlur } = useIsFocused( props ); + + const { selected, disabled, testID } = props; + const state = useMemo( () => ( { + isDisabled: !!disabled, + isSelected: !!selected, + isPressed, + isHovered, + isFocused + } ), [ disabled, selected, isFocused, isHovered, isPressed ] ); + + const { + __icon: iconProps, + ...restProps + } = useComponentPropsResolver( 'RadioButton', props, state ) as IIconButtonProps; + const name = ( props.selected ? selectedIcon : unselectedIcon ) as string; + + const iconPropsWithTestId = { ...iconProps, testID: !!testID && `${testID}-icon` }; + + return { + iconProps: iconPropsWithTestId, + restProps: { + ...restProps, + name, + onPressIn, + onPressOut, + onHoverIn, + onHoverOut, + onFocus, + onBlur + } + }; +}; + +export default useRadioButtonPropsResolver; diff --git a/src/components/main/RadioButton/index.tsx b/src/components/main/RadioButton/index.tsx new file mode 100644 index 0000000..9d1538a --- /dev/null +++ b/src/components/main/RadioButton/index.tsx @@ -0,0 +1,38 @@ +import React, { forwardRef, memo } from 'react'; +import UIKitIcon from '../../../icons/UIKitIcon'; +import IconButton from '../IconButton'; +import useRadioButtonPropsResolver from './hooks'; +import type { IRadioButtonProps } from './types'; + +const RadioButton = ( { + as = UIKitIcon, + selectedIcon = 'circle-filled', + unselectedIcon = 'circle', + selected = false, + ...props +}: IRadioButtonProps, ref?: any ) => { + const { iconProps, restProps } = useRadioButtonPropsResolver( { + ...props, + selectedIcon, + unselectedIcon, + selected + } ); + + return ( + + ); +}; + +export default memo( forwardRef( RadioButton ) ); diff --git a/src/components/main/RadioButton/types.ts b/src/components/main/RadioButton/types.ts new file mode 100644 index 0000000..fc2d8a5 --- /dev/null +++ b/src/components/main/RadioButton/types.ts @@ -0,0 +1,11 @@ +import type { ComponentStyledProps } from '../../../core/components/types'; +import type { IPressableProps } from '../Pressable/types'; +import type { IIconProps } from '../Icon/types'; + +export interface IRadioButtonProps extends Omit, +ComponentStyledProps<'RadioButton'>{ + selectedIcon?: string; + unselectedIcon?: string; + selected?: boolean; + size?: IIconProps['size']; +} diff --git a/src/core/components/types/common.ts b/src/core/components/types/common.ts index 1e5e985..3d27a1b 100644 --- a/src/core/components/types/common.ts +++ b/src/core/components/types/common.ts @@ -1,6 +1,7 @@ import type { StyledProps } from '../../theme/types'; -export type ComponentName = 'Text' | 'Box' | 'Stack' | 'Button' | 'Pressable' | 'Icon' | 'IconButton' | 'Checkbox'; +export type ComponentName = 'Text' | 'Box' | 'Stack' | 'Button' | 'Pressable' | 'Icon' | 'Checkbox' +| 'IconButton' | 'RadioButton'; export type VariantName = string; diff --git a/src/core/components/types/customProps.ts b/src/core/components/types/customProps.ts index 4ff23a5..a7bace9 100644 --- a/src/core/components/types/customProps.ts +++ b/src/core/components/types/customProps.ts @@ -15,6 +15,10 @@ interface IconCustomProps { interface IconButtonCustomProps { as?: any } +// RadioButton +interface RadioButtonCustomProps { + as?: any +} // Stack interface StackCustomProps { @@ -27,6 +31,7 @@ interface StackCustomProps { interface ComponentsCustomPropsConfig { Icon: IconCustomProps, IconButton: IconButtonCustomProps, + RadioButton: RadioButtonCustomProps, Stack: StackCustomProps } diff --git a/src/core/components/types/pseudoProps.ts b/src/core/components/types/pseudoProps.ts index fa1e275..4044402 100644 --- a/src/core/components/types/pseudoProps.ts +++ b/src/core/components/types/pseudoProps.ts @@ -38,8 +38,9 @@ interface ICheckboxPseudoProps { // Pseudoprops config for all components interface ComponentsPseudoPropsConfig { Button: IButtonPseudoProps, - IconButton: IIconButtonPseudoProps - Checkbox: ICheckboxPseudoProps + Checkbox: ICheckboxPseudoProps, + IconButton: IIconButtonPseudoProps, + RadioButton: IIconButtonPseudoProps } // Template type to get pseudoprops for a given component diff --git a/src/core/components/types/state.ts b/src/core/components/types/state.ts index dd5fcac..cf13793 100644 --- a/src/core/components/types/state.ts +++ b/src/core/components/types/state.ts @@ -26,8 +26,8 @@ import type { ValueOf } from '../../types'; export const COMPONENT_STATE_PROPS_MAP = { 'isPressed': '__pressed', - 'isDisabled': '__disabled', 'isSelected': '__selected', + 'isDisabled': '__disabled', 'isIndeterminated': '__indeterminated', 'isHovered': '__hovered', 'isFocused': '__focused' diff --git a/src/core/theme/defaultTheme/components/RadioButton.ts b/src/core/theme/defaultTheme/components/RadioButton.ts new file mode 100644 index 0000000..cf98bb2 --- /dev/null +++ b/src/core/theme/defaultTheme/components/RadioButton.ts @@ -0,0 +1,36 @@ +export default { + defaultProps: { + height: 32, + width: 32, + justifyContent: 'center', + alignItems: 'center', + rounded: 'full', + outlineStyle: 'none', + __icon: { + color: 'neutral.800', + size: 'sm' + }, + __disabled: { + __icon: { + color: 'neutral.600' + } + }, + __selected: { + __icon: { + color: 'secondary.900' + } + }, + __pressed: { + backgroundColor: 'secondary.50' + }, + __hovered: { + backgroundColor: 'secondary.50' + }, + __focused: { + borderWidth: '1', + backgroundColor: 'secondary.50', + borderColor: 'secondary.900' + } + + } +}; diff --git a/src/core/theme/defaultTheme/components/index.ts b/src/core/theme/defaultTheme/components/index.ts index da575d9..9457a5c 100644 --- a/src/core/theme/defaultTheme/components/index.ts +++ b/src/core/theme/defaultTheme/components/index.ts @@ -4,6 +4,7 @@ import Icon from './Icon'; import IconButton from './IconButton'; import Stack from './Stack'; import Checkbox from './Checkbox'; +import RadioButton from './RadioButton'; export default { Text, @@ -11,5 +12,6 @@ export default { IconButton, Button, Stack, - Checkbox + Checkbox, + RadioButton }; diff --git a/src/core/theme/defaultTheme/palette.ts b/src/core/theme/defaultTheme/palette.ts index bca4e37..98ae2af 100644 --- a/src/core/theme/defaultTheme/palette.ts +++ b/src/core/theme/defaultTheme/palette.ts @@ -14,6 +14,7 @@ export default { 600: '#95B3FF', 400: '#B9CCFF', 200: '#DCE6FF', + 50: '#EDF2FF', 10: '#EDF2FF' }, neutral: { diff --git a/src/index.tsx b/src/index.tsx index 0083be3..eb92aa0 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,3 +1,5 @@ +import UIKitIcon from './icons/UIKitIcon'; + export { default as Theme } from './core/theme/Theme'; export { default as defaultTheme } from './core/theme/defaultTheme'; export { default as extendThemeConfig } from './core/theme/extendThemeConfig'; @@ -11,5 +13,7 @@ export { IconButton, VStack, Text, - Checkbox + Checkbox, + RadioButton } from './components'; +export { UIKitIcon }; diff --git a/tests/components/main/RadioButton.test.js b/tests/components/main/RadioButton.test.js new file mode 100644 index 0000000..17a2f0f --- /dev/null +++ b/tests/components/main/RadioButton.test.js @@ -0,0 +1,95 @@ +import { fireEvent, render } from '@testing-library/react-native'; +import React from 'react'; +import { + RadioButton, ThemeProvider +} from '../../../src'; + +const TEST_ID = 'test-radio-button'; + +const accessibilityTest = ( { + getByTestId, + testID = TEST_ID, + checked = false, + disabled = false +} ) => { + expect( getByTestId( testID ) ).toHaveProp( 'accessible', true ); + expect( getByTestId( testID ) ).toHaveProp( 'accessibilityRole', 'radio' ); + expect( getByTestId( testID ) ).toHaveProp( 'accessibilityState', { checked, disabled } ); +}; + +const hasChildWithProp = ( elem, prop, value ) => { + let hasProperty = false; + for ( let index = 0; index < elem.children.length && !hasProperty; index += 1 ) { + const element = elem.children[ index ]; + if ( element.props[ prop ] === value || hasChildWithProp( element, prop, value ) ) { + hasProperty = true; + } + } + return hasProperty; +}; + +describe( 'RadioButton', () => { + const renderRadioButton = ( { selected = false, onPress, disabled } = {} ) => render( + + + + ); + + test( 'renders the unselected style when unselected', () => { + const { getByTestId } = renderRadioButton(); + const icon = getByTestId( `${TEST_ID}-icon` ); + expect( hasChildWithProp( icon, 'stroke', '#676A79' ) ).toBe( true ); + accessibilityTest( { getByTestId } ); + } ); + + test( 'renders the selected style when selected', () => { + const { getByTestId } = renderRadioButton( { selected: true } ); + const icon = getByTestId( `${TEST_ID}-icon` ); + expect( hasChildWithProp( icon, 'stroke', '#4F80FF' ) ).toBe( true ); + accessibilityTest( { getByTestId, checked: true } ); + } ); + + describe( 'when onPress function is provided', () => { + test( 'calls onPress function when pressed', () => { + const onPress = jest.fn(); + const { getByTestId } = renderRadioButton( { onPress } ); + fireEvent.press( getByTestId( TEST_ID ) ); + expect( onPress ).toHaveBeenCalled(); + } ); + } ); + + describe( 'when the radio button is disabled', () => { + describe( 'when pressed', () => { + test( 'does not calls onPress', () => { + const onPress = jest.fn(); + const { queryByTestId } = renderRadioButton( { disabled: true, onPress } ); + fireEvent.press( queryByTestId( TEST_ID ) ); + expect( onPress ).not.toHaveBeenCalled(); + } ); + } ); + describe( 'when is unselected', () => { + it( 'should render the disabled style', () => { + const { getByTestId } = renderRadioButton( { disabled: true } ); + const icon = getByTestId( `${TEST_ID}-icon` ); + expect( hasChildWithProp( icon, 'stroke', '#B0B4CD' ) ).toBe( true ); + accessibilityTest( { getByTestId, disabled: true } ); + } ); + } ); + + describe( 'when is selected', () => { + it( 'should render the disabled style', () => { + const { getByTestId } = renderRadioButton( { disabled: true, selected: true } ); + const icon = getByTestId( `${TEST_ID}-icon` ); + expect( hasChildWithProp( icon, 'stroke', '#B0B4CD' ) ).toBe( true ); + accessibilityTest( { + getByTestId, disabled: true, checked: true + } ); + } ); + } ); + } ); +} ); diff --git a/web_example/src/App.tsx b/web_example/src/App.tsx index cd35d2e..69f7ccb 100644 --- a/web_example/src/App.tsx +++ b/web_example/src/App.tsx @@ -13,6 +13,7 @@ import ButtonExamples from './components/ButtonExamples'; import IconExamples from './components/IconExamples'; import IconButtonExamples from './components/IconButtonExamples'; import CheckboxExamples from './components/CheckboxExamples'; +import RadioButtonExamples from './components/RadioButtonExamples'; const customTheme = extendThemeConfig( { palette: { @@ -96,6 +97,7 @@ export default function App() { + diff --git a/web_example/src/components/RadioButtonExamples.tsx b/web_example/src/components/RadioButtonExamples.tsx new file mode 100644 index 0000000..79a0345 --- /dev/null +++ b/web_example/src/components/RadioButtonExamples.tsx @@ -0,0 +1,64 @@ +/* eslint-disable no-alert */ +import * as React from 'react'; + +import { StyleSheet, View } from 'react-native'; +import { + RadioButton, VStack, Text, HStack +} from '@amalgama/react-native-ui-kit'; + +const styles = StyleSheet.create( { + container: { + width: '100%', + marginBottom: 20 + }, + separator: { + height: 1, + minWidth: '100%', + marginTop: 2, + marginBottom: 6, + backgroundColor: 'black' + }, + vspace: { + height: 10, + minWidth: '100%' + } +} ); + +const RadioButtonExamples = () => { + const [ selected, setSelected ] = React.useState( false ); + return ( + + RadioButton Component + + Enabled + + + + + + + + + Disabled + + + + + + + + + On press + + + + setSelected( prev => !prev )} /> + + + + + + ); +}; + +export default RadioButtonExamples;