From 93ac0ab90451211545f3ea05d70e8e13b40c7864 Mon Sep 17 00:00:00 2001 From: harry-vu Date: Tue, 4 Mar 2025 12:07:29 +0700 Subject: [PATCH 1/2] feat(components/radio): add component, add to example, add to doc, add test --- .../docs/v2/Components/Inputs/radio.en-US.mdx | 160 +++++++++++++ apps/examples/app/components-v2/Radio.tsx | 110 +++++++++ apps/examples/app/items-v2.ts | 2 + packages/components/src/index.ts | 1 + packages/components/src/radio/group.tsx | 62 ++++++ packages/components/src/radio/index.tsx | 2 + packages/components/src/radio/radio.spec.tsx | 90 ++++++++ packages/components/src/radio/radio.tsx | 210 ++++++++++++++++++ .../src/components/button/button.service.ts | 1 - packages/theme/src/foundations/index.ts | 2 + packages/theme/src/foundations/radio.ts | 6 + packages/theme/src/index.ts | 1 + packages/theme/src/theme.service.ts | 119 ++++++++++ packages/theme/src/utilities/index.ts | 102 +++++++++ 14 files changed, 867 insertions(+), 1 deletion(-) create mode 100644 apps/docs/pages/docs/v2/Components/Inputs/radio.en-US.mdx create mode 100644 apps/examples/app/components-v2/Radio.tsx create mode 100644 packages/components/src/radio/group.tsx create mode 100644 packages/components/src/radio/index.tsx create mode 100644 packages/components/src/radio/radio.spec.tsx create mode 100644 packages/components/src/radio/radio.tsx create mode 100644 packages/theme/src/foundations/radio.ts create mode 100644 packages/theme/src/theme.service.ts create mode 100644 packages/theme/src/utilities/index.ts diff --git a/apps/docs/pages/docs/v2/Components/Inputs/radio.en-US.mdx b/apps/docs/pages/docs/v2/Components/Inputs/radio.en-US.mdx new file mode 100644 index 00000000..0d96f7a0 --- /dev/null +++ b/apps/docs/pages/docs/v2/Components/Inputs/radio.en-US.mdx @@ -0,0 +1,160 @@ +--- +searchable: true +--- + +import { CodeEditor } from '@components/code-editor'; +import PropsTable from "@components/docs/props-table"; + +# Radio + +Component to render a radio input. + +## Import + +```js +import { Radio, RadioGroup } from "@ficus-ui/native"; +``` + +## Usage + +### Simple radio + + + + + + + +`} /> + +### Radio group + + + Option 1} /> + Option 2} /> + Option 3} /> +`} /> + +### Radio sizes + + + + + Option 1 + + + Option 2 + + + + Loading option + +`} /> + +### Custom radio + + + {['Option 1', 'Option 2', 'Option 3'].map((item) => ( + + {({ isChecked }) => ( + + {item} + + )} + + ))} +`} /> + +## Props + +### Radio props + +Extends every `Box` props. + +### `colorScheme` + + +### `defaultChecked` + + +### `isChecked` + + +### `isDisabled` + + +### `isLoading` + + +### `onChecked` + void", required: false }} +/> + +### `icon` + + +### `iconColor` + + +### `size` + + +### RadioGroup props + +### `onChange` + void", required: false }} +/> + +### `value` + + +### `defaultValue` + + +### `colorScheme` + \ No newline at end of file diff --git a/apps/examples/app/components-v2/Radio.tsx b/apps/examples/app/components-v2/Radio.tsx new file mode 100644 index 00000000..4a04da05 --- /dev/null +++ b/apps/examples/app/components-v2/Radio.tsx @@ -0,0 +1,110 @@ +import { SafeAreaView } from "react-native"; +import { Badge, Box, Radio, RadioGroup, Text } from '@ficus-ui/native' +import ExampleSection from "@/src/ExampleSection"; + +const RadioComponent = () => { + return ( + + + Radio component + + + + Label + + Label + + + Label + + + Label + + + Label + + + + + + + Option 1}> + Label + + Option 2}> + Label + + Option 3}> + Label + + + + + + + + Option 1 + + + Option 2 + + + + Loading option + + + + + + + {({ isChecked }) => ( + + Option 1 + + )} + + + {({ isChecked }) => ( + + Option 2 + + )} + + + {({ isChecked }) => ( + + Option 3 + + )} + + + + + ); +}; + +export default RadioComponent; diff --git a/apps/examples/app/items-v2.ts b/apps/examples/app/items-v2.ts index 370d0ae4..1afb9243 100644 --- a/apps/examples/app/items-v2.ts +++ b/apps/examples/app/items-v2.ts @@ -11,6 +11,7 @@ import TouchableHighlightComponent from './components-v2/TouchableHighlight'; import TouchableOpacityComponent from './components-v2/TouchableOpacity'; import TouchableWithoutFeedbackComponent from './components-v2/TouchableWithoutFeedback'; import PressableComponent from './components-v2/Pressable'; +import RadioComponent from '@/app/components-v2/Radio'; type ExampleComponentType = { onScreenName: string; @@ -31,4 +32,5 @@ export const components: ExampleComponentType[] = [ { navigationPath: 'TouchableOpacity', onScreenName: 'TouchableOpacity', component: TouchableOpacityComponent }, { navigationPath: 'TouchableWithoutFeedback', onScreenName: 'TouchableWithoutFeedback', component: TouchableWithoutFeedbackComponent }, { navigationPath: 'Pressable', onScreenName: 'Pressable', component: PressableComponent }, + { navigationPath: 'Radio', onScreenName: 'Radio', component: RadioComponent }, ] diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 563335fc..d360d62d 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -8,5 +8,6 @@ export * from './center'; export * from './badge'; export * from './touchables'; export * from './pressable'; +export * from './radio'; export { ThemeProvider } from '@ficus-ui/theme'; diff --git a/packages/components/src/radio/group.tsx b/packages/components/src/radio/group.tsx new file mode 100644 index 00000000..9300bec5 --- /dev/null +++ b/packages/components/src/radio/group.tsx @@ -0,0 +1,62 @@ +import * as React from 'react'; +import { useState } from 'react'; + +import { type NativeFicusProps, ficus, forwardRef } from '../system'; +import { RadioOptions } from './radio'; + +export interface RadioProps + extends Omit, 'children'>, + RadioOptions {} + +export const RadioGroup = forwardRef( + function Radio(props, ref) { + const [value, setValue] = useState( + props.value ?? props.defaultValue ?? null + ); + const { + children, + onChange: onChangeProp, + value: propsValue, + colorScheme, + ...rest + } = props; + + /** + * checks if checked value is already in the state or not, + * if it, remove it else add it + * + * @param value + */ + const onChange = (optionValue: any) => { + if (!('value' in props)) { + setValue(optionValue); + } + + if (onChangeProp) { + onChangeProp(optionValue); + } + }; + + /** + * clones the children and add isChecked, onChange prop + */ + const renderChildren = () => { + return React.Children.map( + children as any, + (child: React.ReactElement) => { + return React.cloneElement(child, { + onChange, + isChecked: value === child.props.value, + ...(colorScheme ? { colorScheme } : {}), + }); + } + ); + }; + + return ( + + {renderChildren()} + + ); + } +); diff --git a/packages/components/src/radio/index.tsx b/packages/components/src/radio/index.tsx new file mode 100644 index 00000000..bfa7d3b7 --- /dev/null +++ b/packages/components/src/radio/index.tsx @@ -0,0 +1,2 @@ +export { Radio } from './radio'; +export { RadioGroup } from './group'; diff --git a/packages/components/src/radio/radio.spec.tsx b/packages/components/src/radio/radio.spec.tsx new file mode 100644 index 00000000..3f375b95 --- /dev/null +++ b/packages/components/src/radio/radio.spec.tsx @@ -0,0 +1,90 @@ +import { theme } from '@ficus-ui/theme'; +import { renderWithTheme as render } from '@test-utils'; +import { fireEvent } from '@testing-library/react-native'; + +import { RadioGroup } from './group'; +import { Radio } from './radio'; + +jest.mock('react-native-toast-message', () => 'Toast'); + +describe('Radio component', () => { + it('renders default radio button', () => { + const { getByTestId } = render( + Option 1 + ); + + expect(getByTestId('radio-default')).toBeTruthy(); + }); + + it('changes state when pressed', () => { + const { getByTestId, getByText } = render( + Option 1 + ); + + const radio = getByTestId('radio-clickable'); + fireEvent.press(radio); + + expect(getByText('X')).toBeTruthy(); // 'X' appears when checked + }); + + it('does not trigger onPress when disabled', () => { + const onPressMock = jest.fn(); + const { getByTestId } = render( + + Disabled + + ); + + fireEvent.press(getByTestId('radio-disabled')); + + expect(onPressMock).not.toHaveBeenCalled(); + }); + describe('RadioGroup component', () => { + it('renders multiple radio buttons', () => { + const { getByTestId } = render( + + + Option 1 + + + Option 2 + + + ); + + expect(getByTestId('radio-one')).toBeTruthy(); + expect(getByTestId('radio-two')).toBeTruthy(); + }); + + it('selects only one radio at a time', () => { + const { getByTestId, getByText } = render( + + + Option 1 + + + Option 2 + + + ); + + fireEvent.press(getByTestId('radio-one')); + expect(getByText('X')).toBeTruthy(); + }); + + it('calls onChange when an option is selected', () => { + const onChangeMock = jest.fn(); + const { getByTestId } = render( + + + Option 1 + + + ); + + fireEvent.press(getByTestId('radio-one')); + + expect(onChangeMock).toHaveBeenCalledWith('one'); + }); + }); +}); diff --git a/packages/components/src/radio/radio.tsx b/packages/components/src/radio/radio.tsx new file mode 100644 index 00000000..a7c129b1 --- /dev/null +++ b/packages/components/src/radio/radio.tsx @@ -0,0 +1,210 @@ +import { ReactNode, useEffect, useState } from 'react'; + +import { isFunction } from '@chakra-ui/utils'; +import { TextStyleProps, splitTextProps } from '@ficus-ui/style-system'; +import { getThemeProperty, theme } from '@ficus-ui/theme'; + +import { + type NativeFicusProps, + ficus, + forwardRef, + useStyleConfig, +} from '../system'; + +export interface RadioStates { + isFocused?: boolean; + isChecked?: boolean; + isDisabled?: boolean; + isLoading?: boolean; +} + +export interface RadioOptions extends RadioStates { + prefix?: ReactNode; + suffix?: ReactNode; + colorScheme?: string; + defaultChecked?: boolean; + icon?: string | React.ReactNode; + iconColor?: string; + isChecked?: boolean; + isLoading?: boolean; + onChange?: (value: any) => void; + value?: any; + children: ((states: RadioStates) => React.ReactNode) | React.ReactNode; + onPress?: (value: any) => void; + defaultValue?: any; + size?: 'sm' | 'lg'; +} + +export interface RadioProps + extends Omit, 'children' | 'onPress'>, + TextStyleProps, + RadioOptions {} + +export const Radio = forwardRef( + function Radio(props, ref) { + const styles = useStyleConfig('Radio', props); + + const { + children, + prefix, + suffix, + value, + isLoading, + isDisabled, + onPress: onPressProp, + onChange, + isChecked, + size = 'sm', + colorScheme, + ...rest + } = props; + const [textStyles, restStyles] = splitTextProps(styles); + const [checked, setChecked] = useState( + props.isChecked || props.defaultChecked ? true : false + ); + const [isFocused, setIsFocused] = useState(false); + const onPress = (event: any) => { + if (isDisabled) { + return; + } + + setChecked(!checked); + + if (isFunction(onPressProp)) { + onPressProp(event); + } + + if (isFunction(onChange)) { + onChange(value); + } + }; + + /** + * sets focussed to true + * + * @param event + */ + const onPressIn = () => { + setIsFocused(true); + }; + + /** + * sets focussed to true + * + * @param event + */ + const onPressOut = () => { + setIsFocused(false); + }; + // const getIcon = () => { // will do after merged + // if (isLoading) { + // return ( + // + // + // + // ); + // } + + // if (props.icon && typeof icon === 'string') { + // return ( + // + // ); + // } + + // if (props.icon) { + // return icon; + // } + + // return ( + // + // ); + // }; + + // const iconObj = getIcon(); + + const renderChildren = () => { + if (isFunction(children)) { + return children({ + isFocused, + isDisabled: isDisabled ?? false, + isChecked: checked, + isLoading, + }); + } + + return ( + <> + {prefix} + + + {/* {iconObj} */} + {checked ? 'X' : ''} + + + {children && typeof children === 'string' ? ( + + {children} + + ) : ( + children + )} + {suffix} + + ); + }; + + return ( + + + {renderChildren()} + + + ); + } +); diff --git a/packages/react-native-ficus-ui/src/components/button/button.service.ts b/packages/react-native-ficus-ui/src/components/button/button.service.ts index 8bcfbe00..3c5165bb 100644 --- a/packages/react-native-ficus-ui/src/components/button/button.service.ts +++ b/packages/react-native-ficus-ui/src/components/button/button.service.ts @@ -1,4 +1,3 @@ -/* eslint-disable */ // @ts-nocheck flemme de corriger alors que je vais tout cramer import color from 'color'; diff --git a/packages/theme/src/foundations/index.ts b/packages/theme/src/foundations/index.ts index 1c0baf6f..23fba8e1 100644 --- a/packages/theme/src/foundations/index.ts +++ b/packages/theme/src/foundations/index.ts @@ -1,5 +1,6 @@ import breakpoints from './breakpoints'; import colors from './colors'; +import radio from './radio'; import radius from './radius'; import shadows from './shadows'; import space from './space'; @@ -11,5 +12,6 @@ export const foundations = { radius, shadows, space, + radio, ...typography, }; diff --git a/packages/theme/src/foundations/radio.ts b/packages/theme/src/foundations/radio.ts new file mode 100644 index 00000000..22c3b666 --- /dev/null +++ b/packages/theme/src/foundations/radio.ts @@ -0,0 +1,6 @@ +const radio = { + sm: 26, + lg: 36, +}; + +export default radio; diff --git a/packages/theme/src/index.ts b/packages/theme/src/index.ts index 351bbf91..4576e93b 100644 --- a/packages/theme/src/index.ts +++ b/packages/theme/src/index.ts @@ -1,3 +1,4 @@ export { theme, type Theme } from './theme.default'; export { ThemeProvider } from './provider'; export * from './hook'; +export * from './theme.service'; diff --git a/packages/theme/src/theme.service.ts b/packages/theme/src/theme.service.ts new file mode 100644 index 00000000..21f610ca --- /dev/null +++ b/packages/theme/src/theme.service.ts @@ -0,0 +1,119 @@ +import { isValidColor } from './utilities'; + +/** + * Get real theme color value + * + * @param themeColors + * @param value + */ +export const getThemeColor = ( + themeColors: any, + value: string | undefined +): string => { + let colorValueResult: string | String = value as string; + + if (themeColors && value) { + // Check if color value is a valid theme color + if ( + themeColors.hasOwnProperty(value) && + (typeof themeColors[value] === 'string' || + themeColors[value] instanceof String) + ) { + const colorValue: string | String = themeColors[value] as string; + return colorValue as string; + } + + // If color value contains dots, check into theme sub objects if it's a valid theme color + if (value.includes('.')) { + const keyParts = value.split('.'); + let subPropertyValue: any = themeColors; + for (const part of keyParts) { + if (subPropertyValue && part) { + subPropertyValue = subPropertyValue[part]; + } + } + if ( + typeof subPropertyValue === 'string' || + subPropertyValue instanceof String + ) { + colorValueResult = subPropertyValue; + } + } + } + + return isValidColor(colorValueResult as string) + ? (colorValueResult as string) + : 'transparent'; +}; + +/** + * extract the theme property from theme + * if thereis no theme property in the value, return the value + * + * @param theme + * @param value + */ +export const getThemeProperty = (theme: any, value: any) => { + if (theme) { + if (typeof theme[value] !== 'undefined') { + return theme[value]; + } + } + return value; +}; + +export const stringToColor = (str: string) => { + // Step 1: Create a hash from the string + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + + // Step 2: Convert the hash into a color code (in hex format) + let color = '#'; + for (let i = 0; i < 3; i++) { + // Bitwise operation to get the first 3 bytes and map it to a valid hex value + let value = (hash >> (i * 8)) & 0xff; + color += ('00' + value.toString(16)).substr(-2); + } + + return color; +}; + +export const lightenColor = (color: string, percent: number) => { + const num = parseInt(color.slice(1), 16); + const amt = Math.round(2.55 * percent); + const R = (num >> 16) + amt; + const G = ((num >> 8) & 0x00ff) + amt; + const B = (num & 0x0000ff) + amt; + return ( + '#' + + ( + 0x1000000 + + (R < 255 ? (R < 1 ? 0 : R) : 255) * 0x10000 + + (G < 255 ? (G < 1 ? 0 : G) : 255) * 0x100 + + (B < 255 ? (B < 1 ? 0 : B) : 255) + ) + .toString(16) + .slice(1) + ); +}; + +export const darkenColor = (color: string, percent: number) => { + const num = parseInt(color.slice(1), 16); + const amt = Math.round(2.55 * percent); + const R = (num >> 16) - amt; + const G = ((num >> 8) & 0x00ff) - amt; + const B = (num & 0x0000ff) - amt; + return ( + '#' + + ( + 0x1000000 + + (R < 255 ? (R < 1 ? 0 : R) : 255) * 0x10000 + + (G < 255 ? (G < 1 ? 0 : G) : 255) * 0x100 + + (B < 255 ? (B < 1 ? 0 : B) : 255) + ) + .toString(16) + .slice(1) + ); +}; diff --git a/packages/theme/src/utilities/index.ts b/packages/theme/src/utilities/index.ts new file mode 100644 index 00000000..83c49128 --- /dev/null +++ b/packages/theme/src/utilities/index.ts @@ -0,0 +1,102 @@ +import * as React from 'react'; +import { useEffect, useRef, useState } from 'react'; + +import { Dimensions } from 'react-native'; +import { + validateHTMLColorHex, + validateHTMLColorHsl, + validateHTMLColorName, + validateHTMLColorRgb, + validateHTMLColorSpecialName, +} from 'validate-color'; + +const WINDOW = Dimensions.get('window'); + +export const WINDOW_WIDTH = WINDOW.width; +export const WINDOW_HEIGHT = WINDOW.height; + +//is the value an empty array? +export const isEmptyArray = (value?: any) => + Array.isArray(value) && value.length === 0; + +// is the given object a Function? +export const isFunction = (obj: any): obj is Function => + typeof obj === 'function'; + +// is the given object an Object? +export const isObject = (obj: any): obj is Object => + obj !== null && typeof obj === 'object'; + +// is the given object an integer? +export const isInteger = (obj: any): boolean => + String(Math.floor(Number(obj))) === obj; + +// is the given object a string? +export const isString = (obj: any): obj is string => + Object.prototype.toString.call(obj) === '[object String]'; + +// is the given object a NaN? +// eslint-disable-next-line no-self-compare +export const isNaN = (obj: any): boolean => obj !== obj; + +// Does a React component have exactly 0 children? +export const isEmptyChildren = (children: any): boolean => + React.Children.count(children) === 0; + +// is the given object/value a promise? +export const isPromise = (value: any): value is PromiseLike => + isObject(value) && isFunction(value.then); + +// is the given object/value a type of synthetic event? +export const isInputEvent = (value: any): value is React.SyntheticEvent => + value && isObject(value) && isObject(value.target); + +/** + * useState with callback + * + * @param initialState + */ +export const useStateCallback = (initialState: any) => { + const [state, setState] = useState(initialState); + const cbRef = useRef(null); // mutable ref to store current callback + + const setStateCallback = (newState: any, cb: any) => { + cbRef.current = cb; // store passed callback to ref + setState(newState); + }; + + useEffect(() => { + // cb.current is `null` on initial render, so we only execute cb on state *updates* + if (cbRef.current) { + //@ts-ignore + cbRef.current(state); + cbRef.current = null; // reset callback after execution + } + }, [state]); + + return [state, setStateCallback]; +}; + +export const isValidColor = (color: string): boolean => { + return ( + validateHTMLColorRgb(color) || + validateHTMLColorSpecialName(color) || + validateHTMLColorHex(color) || + validateHTMLColorHsl(color) || + validateHTMLColorName(color) + ); +}; + +export const getSpecificProps = (obj: T, ...keys: string[]) => + //@ts-ignore + keys.reduce((a, c) => ({ ...a, [c]: obj[c] }), {}); + +export const removeSpecificProps = ( + obj: T, + ...keys: string[] +): T => + keys.reduce((a, c) => { + //@ts-ignore + delete a[c]; + return a; + }, obj); From 20cdcd383002725e22e96ff157823a15f7ff17e3 Mon Sep 17 00:00:00 2001 From: harry-vu Date: Tue, 4 Mar 2025 12:45:54 +0700 Subject: [PATCH 2/2] chore: fixed wrong behavior --- packages/components/src/radio/radio.spec.tsx | 1 - packages/components/src/radio/radio.tsx | 20 ++++++++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/components/src/radio/radio.spec.tsx b/packages/components/src/radio/radio.spec.tsx index 3f375b95..9bf63db7 100644 --- a/packages/components/src/radio/radio.spec.tsx +++ b/packages/components/src/radio/radio.spec.tsx @@ -1,4 +1,3 @@ -import { theme } from '@ficus-ui/theme'; import { renderWithTheme as render } from '@test-utils'; import { fireEvent } from '@testing-library/react-native'; diff --git a/packages/components/src/radio/radio.tsx b/packages/components/src/radio/radio.tsx index a7c129b1..00a69e1c 100644 --- a/packages/components/src/radio/radio.tsx +++ b/packages/components/src/radio/radio.tsx @@ -60,15 +60,21 @@ export const Radio = forwardRef( } = props; const [textStyles, restStyles] = splitTextProps(styles); const [checked, setChecked] = useState( - props.isChecked || props.defaultChecked ? true : false + isChecked || props.defaultChecked ? true : false ); const [isFocused, setIsFocused] = useState(false); + useEffect(() => { + if ('isChecked' in props) { + setChecked(isChecked ?? false); + } + }, [props]); const onPress = (event: any) => { if (isDisabled) { return; } - - setChecked(!checked); + if (!('isChecked' in props)) { + setChecked(true); + } if (isFunction(onPressProp)) { onPressProp(event); @@ -158,12 +164,18 @@ export const Radio = forwardRef( h={getThemeProperty(theme.radio, size)} w={getThemeProperty(theme.radio, size)} borderWidth={1} + borderRadius={getThemeProperty(theme.radio, size)} borderColor={`${colorScheme}.600`} alignItems="center" justifyContent="center" > {/* {iconObj} */} - {checked ? 'X' : ''} + {children && typeof children === 'string' ? (