diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e63845a1..48379bca 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -145,7 +145,7 @@ The `package.json` file contains various scripts for common tasks: - `pnpm typecheck`: type-check files with TypeScript. - `pnpm lint`: lint files with ESLint. -- `pnpm test:components`: run the library's components unit tests with Jest. +- `pnpm test`: run the library's components unit tests with Jest. - `pnpm examples start`: start the Metro server for the example app. - `pnpm examples android`: run the example app on Android. - `pnpm examples ios`: run the example app on iOS. diff --git a/apps/docs/pages/docs/v2/Components/Touchables/button.en-US.mdx b/apps/docs/pages/docs/v2/Components/Touchables/button.en-US.mdx new file mode 100644 index 00000000..b9fc42b4 --- /dev/null +++ b/apps/docs/pages/docs/v2/Components/Touchables/button.en-US.mdx @@ -0,0 +1,162 @@ +--- +searchable: true +--- + +import { CodeEditor } from '@components/code-editor'; +import PropsTable from '@components/docs/props-table'; + + +# Button + +Button component that is based on react native `Button` component. + +## Import + +```js +import { Button } from 'react-native-ficus-ui'; +``` + +## Usage + +### Default + + + + + + + + + +`} /> + +### Button sizes + + + + + + + + +`} /> + +### Round + + + + + + + +`} /> + +### Variants + + + + + + + + + + + + + + +`} /> + +### Prefix and suffix + + + + +`} /> + +## Props + +Extends every `Box` props and react native `Button` component. + +https://reactnative.dev/docs/button#props + +### `style` +", required: false, default: "-" }} +/> + +### `colorScheme` + + +### `size` + + +### `full` + + +### `isRound` + + +### `_pressed` + \ No newline at end of file diff --git a/apps/examples/app/components-v2/Button.tsx b/apps/examples/app/components-v2/Button.tsx new file mode 100644 index 00000000..ca4eaf76 --- /dev/null +++ b/apps/examples/app/components-v2/Button.tsx @@ -0,0 +1,117 @@ +import { Button, HStack, SafeAreaBox, ScrollBox, Stack, Text } from '@ficus-ui/native'; +import { Icon } from 'react-native-ficus-ui'; + +import ExampleSection from '@/src/ExampleSection'; + + +const ButtonComponent = () => { + return ( + + + Button component + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default ButtonComponent; diff --git a/apps/examples/app/items-v2.ts b/apps/examples/app/items-v2.ts index a1f48aa8..19811364 100644 --- a/apps/examples/app/items-v2.ts +++ b/apps/examples/app/items-v2.ts @@ -12,6 +12,7 @@ import TouchableOpacityComponent from './components-v2/TouchableOpacity'; import TouchableWithoutFeedbackComponent from './components-v2/TouchableWithoutFeedback'; import ImageComponent from '@/app/components-v2/Image'; import PressableComponent from './components-v2/Pressable'; +import ButtonComponent from './components-v2/Button'; import DividerComponent from '@/app/components-v2/Divider'; import SpinnerComponent from '@/app/components-v2/Spinner'; import SliderComponent from '@/app/components-v2/Slider'; @@ -42,6 +43,7 @@ export const components: ExampleComponentType[] = [ { navigationPath: 'TouchableOpacity', onScreenName: 'TouchableOpacity', component: TouchableOpacityComponent }, { navigationPath: 'TouchableWithoutFeedback', onScreenName: 'TouchableWithoutFeedback', component: TouchableWithoutFeedbackComponent }, { navigationPath: 'Image', onScreenName: 'Image', component: ImageComponent }, + { navigationPath: 'Button', onScreenName: 'Button', component: ButtonComponent }, { navigationPath: 'Divider', onScreenName: 'Divider', component: DividerComponent }, { navigationPath: 'Spinner', onScreenName: 'Spinner', component: SpinnerComponent }, { navigationPath: 'Pressable', onScreenName: 'Pressable', component: PressableComponent }, diff --git a/packages/components/src/avatar/avatar.tsx b/packages/components/src/avatar/avatar.tsx index 7aa0426b..27505496 100644 --- a/packages/components/src/avatar/avatar.tsx +++ b/packages/components/src/avatar/avatar.tsx @@ -23,61 +23,59 @@ export interface AvatarProps ThemingProps<'Avatar'>, AvatarOptions {} -export const Avatar = forwardRef( - function Avatar(props, ref) { - const styles = useMultiStyleConfig('Avatar', props); - const { - borderRadius = 'full', - src, - name, - getInitials, - children, - __styles, - ...rest - } = omitThemingProps(props); +export const Avatar = forwardRef((props, ref) => { + const styles = useMultiStyleConfig('Avatar', props); + const { + borderRadius = 'full', + src, + name, + getInitials, + children, + __styles, + ...rest + } = omitThemingProps(props); - const [textStyles, restProps] = splitTextProps(rest); - const avatarContainerStyles = useMemo( - () => ({ - borderRadius, - alignItems: 'center', - justfyContent: 'center', - ...styles.container, - ...__styles, // Useful for avatar excess label - }), - [styles.container] - ); + const [textStyles, restProps] = splitTextProps(rest); + const avatarContainerStyles = useMemo( + () => ({ + borderRadius, + alignItems: 'center', + justfyContent: 'center', + ...styles.container, + ...__styles, // Useful for avatar excess label + }), + [styles.container] + ); - const labelStyles = useMemo( - () => ({ - ...styles.label, - ...textStyles, - }), - [styles.label, textStyles] - ); + const labelStyles = useMemo( + () => ({ + ...styles.label, + ...textStyles, + }), + [styles.label, textStyles] + ); - return ( - - - {/* - * To avoid using a context, we clone the element and apply the style retrieved with the `useMultiStyleConfig` - */} - {React.Children.map(children as any, (child: React.ReactElement) => { - return React.cloneElement(child, { - __styles: { - ...styles.badge, - ...child.props.__styles, - }, - size: props.size ?? styles.container?.width, - }); - })} - - ); - } -); + return ( + + + {/* + * To avoid using a context, we clone the element and apply the style retrieved with the `useMultiStyleConfig` + */} + {React.Children.map(children as any, (child: React.ReactElement) => { + return React.cloneElement(child, { + __styles: { + ...styles.badge, + ...child.props.__styles, + }, + size: props.size ?? styles.container?.width, + }); + })} + + ); +}); diff --git a/packages/components/src/button/button-spinner.tsx b/packages/components/src/button/button-spinner.tsx new file mode 100644 index 00000000..df35ac68 --- /dev/null +++ b/packages/components/src/button/button-spinner.tsx @@ -0,0 +1,24 @@ +import { FC } from 'react'; + +import { ActivityIndicator } from 'react-native'; + +interface ButtonSpinnerProps { + position?: 'absolute' | 'relative'; + spacing?: number; + loaderColor?: string; +} +export const ButtonSpinner: FC = ({ + position, + spacing, + loaderColor, +}) => { + const buttonSpinnerProps = { + style: { + position, + marginRight: spacing, + }, + color: loaderColor ?? 'white', + } as const; + + return ; +}; diff --git a/packages/components/src/button/button.service.tsx b/packages/components/src/button/button.service.tsx new file mode 100644 index 00000000..0e647ab6 --- /dev/null +++ b/packages/components/src/button/button.service.tsx @@ -0,0 +1,119 @@ +import React, { ReactNode, useMemo } from 'react'; + +import { isFunction, splitProps } from '@chakra-ui/utils'; +import { Dict, SystemStyleObject, isTextProp } from '@ficus-ui/style-system'; + +import { ButtonProps } from '.'; +import { ficus } from '../system'; +import { getStateStyles } from '../system/get-state-styles'; +import { getColor, useTheme } from '@ficus-ui/theme'; + +/** + * Determines if the given React node is a plain text or number. + */ +const isTextNode = (node: any): boolean => + typeof node === 'string' || typeof node === 'number'; + +/** + * Splits children into an array, wrapping text children in `ficus.Text` with applied styles. + */ +export function splitChildren( + children: React.ReactNode, + textStyles: Dict | undefined, + buttonId?: string +): React.ReactNode { + return React.Children.map(children, (child, index) => { + const key = `${buttonId || 'button'}-${index}`; + + if (isTextNode(child)) + return ( + + {child} + + ); + + return {child}; + }); +} + +/** + * Custom hook for managing button behavior and styles. + */ +export function useButton(props: ButtonProps, styles: SystemStyleObject) { + const { + id, + full, + isDisabled, + isLoading, + children, + loadingText, + loaderColor, + } = props; + + const { theme } = useTheme(); + + // Compute styles based on state + const stateStyles = useMemo( + () => + getStateStyles( + { + disabled: isDisabled || isLoading, + }, + styles + ), + [isDisabled, isLoading, styles] + ); + + const [textStyles] = splitProps(stateStyles, isTextProp); + + // Memoized button styles + const buttonStyles = useMemo( + () => ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'row', + width: full ? '100%' : undefined, + ...stateStyles, + }), + [full, stateStyles] + ); + + const resolvedColor = getColor(textStyles?.color, theme.colors); + + // Memoized spinner styles + const spinnerStyles = useMemo( + () => + ({ + position: loadingText ? 'relative' : 'absolute', + spacing: 8, + loaderColor: loaderColor || resolvedColor, + }) as const, + [loadingText, loaderColor] + ); + + return { + buttonStyles, + spinnerStyles, + loadingText: loadingText && ( + {loadingText} + ), + /** + * Retrieves the processed button children. + */ + getChildren() { + if (isFunction(children)) { + if (isLoading) { + /** + * To be able to render it inside of the invisible View when loading + */ + return children({ pressed: false }); + } + + return children; + } + + return splitChildren(children, textStyles, id) as ReactNode; + }, + }; +} diff --git a/packages/components/src/button/button.spec.tsx b/packages/components/src/button/button.spec.tsx new file mode 100644 index 00000000..5b248876 --- /dev/null +++ b/packages/components/src/button/button.spec.tsx @@ -0,0 +1,102 @@ +import React from 'react'; + +import { + cleanup, + fireEvent, + screen, +} from '@testing-library/react-native'; +import { renderWithTheme as render } from '@test-utils'; +import { ActivityIndicator, View } from 'react-native'; +import { theme } from '@ficus-ui/theme'; + +import { Button } from '.'; + +afterEach(cleanup); + +describe('Button component', () => { + it('should render without errors', () => { + const { getByText } = render(); + + expect( + getByText('I love Ficus UI (forked from Magnus UI)') + ).toBeTruthy(); + }); + + it('should pressing trigger event', () => { + const onPressMock = jest.fn(); + + const { getByText } = render(); + + const button = getByText('Click Me'); + + fireEvent.press(button); + + expect(onPressMock).toHaveBeenCalledTimes(1); + }); + + it('should be disabled when isDisabled is true', () => { + const onPressMock = jest.fn(); + + const { getByText } = render( + + ); + + expect(onPressMock).not.toHaveBeenCalled(); + + const button = getByText('Click Me'); + + fireEvent.press(button); + + expect(onPressMock).not.toHaveBeenCalled(); + }); + + it('should render ActivityIndicator component if loading', () => { + render( + ); + + const text = getByText('This is the button'); + + expect(text.props.style).toHaveProperty('color'); + expect(text.props.style.color).toEqual('#000000'); + }); +}); diff --git a/packages/components/src/button/button.types.ts b/packages/components/src/button/button.types.ts new file mode 100644 index 00000000..b1b45056 --- /dev/null +++ b/packages/components/src/button/button.types.ts @@ -0,0 +1,16 @@ +interface ButtonStates { + isLoading?: boolean; + isDisabled?: boolean; +} + +export interface ButtonOptions extends ButtonStates { + /** + * Wether the button should take full width + * @default false + */ + full?: boolean; + + loadingText?: string; + + loaderColor?: string; +} diff --git a/packages/components/src/button/index.tsx b/packages/components/src/button/index.tsx new file mode 100644 index 00000000..9014aaa2 --- /dev/null +++ b/packages/components/src/button/index.tsx @@ -0,0 +1,55 @@ +import { ReactNode } from 'react'; + +import { omit } from '@chakra-ui/utils'; +import { + TextStyleProps, + ThemingProps, + omitThemingProps, +} from '@ficus-ui/style-system'; + +import { Pressable, PressableProps } from '../pressable'; +import { ficus, forwardRef, useStyleConfig } from '../system'; +import { ButtonSpinner } from './button-spinner'; +import { useButton } from './button.service'; +import type { ButtonOptions } from './button.types'; + +export interface ButtonProps + extends PressableProps, + TextStyleProps, + ButtonOptions, + ThemingProps<'Button'> {} + +export const Button = forwardRef( + function Button(props, ref) { + const styles = useStyleConfig('Button', props); + + const { getChildren, buttonStyles, spinnerStyles, loadingText } = useButton( + props, + styles + ); + + const { isLoading, isDisabled, ...rest } = omitThemingProps( + omit(props, ['children']) + ); + + return ( + + {isLoading && } + + {isLoading ? ( + loadingText || ( + {getChildren() as ReactNode} + ) + ) : ( + <>{getChildren()} + )} + + ); + } +); diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 7f75ed27..788ad8b1 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -9,6 +9,7 @@ export * from './badge'; export * from './touchables'; export * from './image'; export * from './pressable'; +export * from './button'; export * from './divider'; export * from './spinner'; export * from './slider'; diff --git a/packages/theme/src/components/button.ts b/packages/theme/src/components/button.ts new file mode 100644 index 00000000..f8150263 --- /dev/null +++ b/packages/theme/src/components/button.ts @@ -0,0 +1,140 @@ +import { defineStyle, defineStyleConfig } from '@ficus-ui/style-system'; + +import { runIfFn } from '../utils/run-if-fn'; + +const baseStyle = defineStyle({ + borderRadius: 'md', + alignSelf: 'flex-start', + fontWeight: 'bold', + _disabled: { + opacity: 0.6, + }, +}); + +const variantGhost = defineStyle((props) => { + const { colorScheme: c } = props; + + if (c === 'gray') { + return { + color: 'gray.800', + _pressed: { + bg: 'gray.100', + }, + }; + } + + return { + color: `${c}.600`, + bg: 'transparent', + _pressed: { + bg: `${c}.50`, + }, + }; +}); + +const variantOutline = defineStyle((props) => { + const { colorScheme: c } = props; + + return { + borderWidth: 1, + borderStyle: 'solid', + borderColor: `${c}.500`, + ...runIfFn(variantGhost, props), + }; +}); + +type AccessibleColor = { + bg?: string; + color?: string; + pressedBg?: string; +}; + +/** Accessible color overrides for less accessible colors. */ +const accessibleColorMap: { [key: string]: AccessibleColor } = { + yellow: { + bg: 'yellow.400', + color: 'black', + pressedBg: 'yellow.500', + }, + cyan: { + bg: 'cyan.400', + color: 'black', + pressedBg: 'cyan.500', + }, +}; + +const variantSolid = defineStyle((props) => { + const { colorScheme: c } = props; + + const { + bg = `${c}.500`, + color = 'white', + pressedBg = `${c}.600`, + } = accessibleColorMap[c] ?? {}; + + return { + bg, + color, + _pressed: { + bg: pressedBg, + }, + }; +}); + +const variantLink = defineStyle((props) => { + const { colorScheme: c } = props; + return { + padding: 0, + lineHeight: 18, + textDecorationLine: 'underline', + textDecorationColor: `${c}.500`, + verticalAlign: 'baseline', + color: `${c}.500`, + }; +}); + +const variants = { + ghost: variantGhost, + outline: variantOutline, + solid: variantSolid, + link: variantLink, +}; + +const sizes = { + xs: defineStyle({ + px: 6, + fontSize: 12, + height: 25, + }), + sm: defineStyle({ + px: 10, + fontSize: 13, + height: 30, + }), + md: defineStyle({ + px: 14, + fontSize: 15, + height: 35, + }), + lg: defineStyle({ + px: 16, + fontSize: 17, + height: 40, + }), + xl: defineStyle({ + px: 18, + fontSize: 19, + height: 45, + }), +}; + +export const buttonTheme = defineStyleConfig({ + baseStyle, + variants, + sizes, + defaultProps: { + variant: 'solid', + size: 'md', + colorScheme: 'gray', + }, +}); diff --git a/packages/theme/src/components/index.ts b/packages/theme/src/components/index.ts index d846fea8..fba85d3b 100644 --- a/packages/theme/src/components/index.ts +++ b/packages/theme/src/components/index.ts @@ -1,14 +1,11 @@ import { avatarTheme } from './avatar'; import { badgeTheme } from './badge'; +import { buttonTheme } from './button'; import { sliderTheme } from './slider'; -export { avatarTheme as Avatar } from './avatar'; - -export { badgeTheme as Badge } from './badge'; -export { sliderTheme as Slider } from './slider'; - export const components = { Avatar: avatarTheme, Badge: badgeTheme, + Button: buttonTheme, Slider: sliderTheme, }; diff --git a/packages/theme/src/utils/run-if-fn.ts b/packages/theme/src/utils/run-if-fn.ts new file mode 100644 index 00000000..33fcf049 --- /dev/null +++ b/packages/theme/src/utils/run-if-fn.ts @@ -0,0 +1,9 @@ +const isFunction = (value: any): value is Function => + typeof value === 'function'; + +export function runIfFn( + valueOrFn: T | ((...fnArgs: U[]) => T), + ...args: U[] +): T { + return isFunction(valueOrFn) ? valueOrFn(...args) : valueOrFn; +}