diff --git a/apps/docs/pages/docs/v2/Components/avatar.en-US.mdx b/apps/docs/pages/docs/v2/Components/avatar.en-US.mdx new file mode 100644 index 00000000..1948aa11 --- /dev/null +++ b/apps/docs/pages/docs/v2/Components/avatar.en-US.mdx @@ -0,0 +1,163 @@ +--- +searchable: true +--- + +import { CodeEditor } from '@components/code-editor'; +import PropsTable from "@components/docs/props-table"; + +# Avatar + +Component to represent user, and displays the profile picture, initials or fallback icon. + +## Import + +```js +import { Avatar, AvatarGroup } from "@ficus-ui/native"; +``` + +## Usage + +### Avatar with photo + +`} /> + +### Generated color + + + + + + + +`} /> + +### Color scheme + + + + + + + +`} /> + +### Avatar group + + + + + + + + + + + + + + + + + + + +`} /> + +### Avatar badge + + + + + + + + + + + +`} /> + +### Avatar badge and group + + + + + + + + + + + + + +`} /> + +## Props + +Extends every `Box` and `Text` props. + +### `colorScheme` + + +### `name` + + +### `size` + + +### `src` + + +### AvatarGroup props + +### `size` + + +### `max` + \ No newline at end of file diff --git a/apps/examples/app/components-v2/Avatar.tsx b/apps/examples/app/components-v2/Avatar.tsx new file mode 100644 index 00000000..c66348c6 --- /dev/null +++ b/apps/examples/app/components-v2/Avatar.tsx @@ -0,0 +1,126 @@ +import { SafeAreaView } from 'react-native'; + +import ExampleSection from '@/src/ExampleSection'; +import { Avatar, AvatarBadge, AvatarGroup, HStack, ScrollBox, Text } from '@ficus-ui/native'; + +const AvatarComponent = () => { + return ( + + + + Avatar component + + + + + + + + {/* + + + + + */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default AvatarComponent; diff --git a/apps/examples/app/items-v2.ts b/apps/examples/app/items-v2.ts index ad80c026..a1f48aa8 100644 --- a/apps/examples/app/items-v2.ts +++ b/apps/examples/app/items-v2.ts @@ -21,6 +21,7 @@ import SectionListComponent from '@/app/components-v2/SectionList'; import FlashListComponent from '@/app/components-v2/FlashList'; import ModalComponent from '@/app/components-v2/Modal'; import DraggableModalComponent from '@/app/components-v2/DraggableModal'; +import AvatarComponent from '@/app/components-v2/Avatar'; type ExampleComponentType = { onScreenName: string; @@ -51,5 +52,5 @@ export const components: ExampleComponentType[] = [ { navigationPath: 'FlashList', onScreenName: 'FlashList', component: FlashListComponent }, { navigationPath: 'Modal', onScreenName: 'Modal', component: ModalComponent }, { navigationPath: 'DraggableModal', onScreenName: 'DraggableModal', component: DraggableModalComponent }, + { navigationPath: 'Avatar', onScreenName: 'Avatar', component: AvatarComponent } ]; - diff --git a/packages/components/src/avatar/avatar-badge.tsx b/packages/components/src/avatar/avatar-badge.tsx new file mode 100644 index 00000000..768c5791 --- /dev/null +++ b/packages/components/src/avatar/avatar-badge.tsx @@ -0,0 +1,19 @@ +import { useMemo } from 'react'; + +import { NativeFicusProps, ficus, forwardRef } from '../system'; + +export interface AvatarBadgeProps extends NativeFicusProps<'View'> {} + +export const AvatarBadge = forwardRef((props) => { + const { __styles } = props; + + const badgeStyles = useMemo( + () => ({ + position: 'absolute', + ...__styles, + }), + [__styles] + ); + + return ; +}); diff --git a/packages/components/src/avatar/avatar-group.tsx b/packages/components/src/avatar/avatar-group.tsx new file mode 100644 index 00000000..74d3e4bb --- /dev/null +++ b/packages/components/src/avatar/avatar-group.tsx @@ -0,0 +1,91 @@ +import React, { type ReactElement, cloneElement, isValidElement } from 'react'; + +import { compact } from '@chakra-ui/utils'; +import { + SystemStyleObject, + ThemingProps, + omitThemingProps, +} from '@ficus-ui/style-system'; + +import { HStack } from '../stack'; +import { NativeFicusProps, forwardRef, useMultiStyleConfig } from '../system'; +import { Avatar } from './avatar'; + +interface AvatarGroupOptions { + max?: number; +} + +export interface AvatarGroupProps + extends NativeFicusProps<'View'>, + ThemingProps<'Avatar'>, + AvatarGroupOptions {} + +/** + * TODO: Possible improvments + */ +export const AvatarGroup = forwardRef((props) => { + const styles = useMultiStyleConfig('Avatar', props); + + const { + children, + max = null, + borderRadius = 'full', + borderColor = 'white', + ...rest + } = omitThemingProps(props); + /** + * Only keep children that are `Avatar` component + */ + const avatarChildren = React.Children.toArray(children).filter( + (element) => isValidElement(element) && element.type === Avatar + ) as ReactElement[]; + + const truncatedChildren = + max != null ? avatarChildren.slice(0, max) : avatarChildren; + + const excess = max != null ? avatarChildren.length - max : 0; + + const reversedChildren = truncatedChildren.reverse(); + const clones = reversedChildren.map((child, index) => { + const isFirstAvatar = index === 0; + + const childProps: SystemStyleObject = { + ml: isFirstAvatar ? 0 : '-lg', + borderWidth: 3, + borderColor, + borderRadius, + size: child?.props.size ? child.props.size : props.size, + }; + + return cloneElement(child, compact(childProps)); + }); + + const groupStyles: SystemStyleObject = { + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-start', + flexDirection: 'row-reverse', + spacing: '0', + ...styles.group, + }; + + const excessStyles: SystemStyleObject = { + borderRadius, + ml: '-lg', + ...styles.excessLabel, + }; + + return ( + + {clones} + {excess > 0 && ( + excessValue} + /> + )} + + ); +}); diff --git a/packages/components/src/avatar/avatar-image.tsx b/packages/components/src/avatar/avatar-image.tsx new file mode 100644 index 00000000..fbfa440c --- /dev/null +++ b/packages/components/src/avatar/avatar-image.tsx @@ -0,0 +1,39 @@ +import { SystemStyleObject } from '@ficus-ui/style-system'; + +import { Image, ImageProps } from '../image'; +import { useImage } from '../image/use-image'; +import { AvatarLabel } from './avatar-label'; +import { AvatarOptions } from './avatar.types'; + +interface AvatarImageProps + extends ImageProps, + Pick { + labelStyles: SystemStyleObject; +} +export function AvatarImage(props: AvatarImageProps) { + const { name, src, getInitials, borderRadius, labelStyles } = props; + + const status = useImage({ src }); + + const hasLoaded = status === 'loaded'; + + const showFallback = !hasLoaded || !src; + + const imageStyles = { + width: '100%', + height: '100%', + borderRadius, + }; + + if (showFallback) { + return ( + + ); + } + + return {name}; +} diff --git a/packages/components/src/avatar/avatar-label.tsx b/packages/components/src/avatar/avatar-label.tsx new file mode 100644 index 00000000..f397cb5f --- /dev/null +++ b/packages/components/src/avatar/avatar-label.tsx @@ -0,0 +1,35 @@ +import { useMemo } from 'react'; + +import { type NativeFicusProps, ficus } from '../system'; +import { AvatarOptions } from './avatar.types'; + +export function initials(name: string) { + const names = name.trim().split(' '); + const firstName = names[0] ?? ''; + const lastName = names.length > 1 ? names[names.length - 1] : ''; + return firstName && lastName + ? `${firstName.charAt(0)}${lastName.charAt(0)}` + : firstName.charAt(0); +} + +export interface AvatarLabelProps + extends NativeFicusProps<'Text'>, + Pick {} + +export function AvatarLabel({ name, getInitials, __styles }: AvatarLabelProps) { + const getInitialsFn = getInitials ?? initials; + + const labelStyles = useMemo( + () => ({ + textAlign: 'center', + textAlignVertical: 'center', + ...__styles, + }), + [__styles] + ); + return ( + + {name ? getInitialsFn?.(name) : null} + + ); +} diff --git a/packages/components/src/avatar/avatar.spec.tsx b/packages/components/src/avatar/avatar.spec.tsx new file mode 100644 index 00000000..8bcf3651 --- /dev/null +++ b/packages/components/src/avatar/avatar.spec.tsx @@ -0,0 +1,64 @@ +import { theme } from '@ficus-ui/theme'; +import { renderWithTheme as render } from '@test-utils'; + +import { Avatar } from '.'; + +jest.mock('react-native-toast-message', () => 'Toast'); + +describe('Avatar component', () => { + it('renders initials when no image is provided', () => { + const { getByText } = render( + + ); + + expect(getByText('JD')).toBeTruthy(); + }); + + it('renders with a default background color', () => { + const { getByTestId } = render( + + ); + + expect(getByTestId('avatar-bg')).toHaveStyle({ + backgroundColor: '#f2a9cd', + }); + }); + + it('renders with a custom color scheme', () => { + const { getByTestId } = render( + + ); + + expect(getByTestId('avatar-color-scheme')).toHaveStyle({ + backgroundColor: theme.colors.blue[300], + }); + }); + + it('renders the image if src is valid', async () => { + global.fetch = jest.fn(() => Promise.resolve({ ok: true })) as jest.Mock; + + const { getByTestId } = render( + + ); + + expect(getByTestId('avatar-image')).toBeTruthy(); + }); + + it('renders initials if image fails to load', async () => { + global.fetch = jest.fn(() => Promise.resolve({ ok: false })) as jest.Mock; + + const { getByText } = render( + + ); + + expect(getByText('JD')).toBeTruthy(); + }); +}); diff --git a/packages/components/src/avatar/avatar.tsx b/packages/components/src/avatar/avatar.tsx new file mode 100644 index 00000000..7aa0426b --- /dev/null +++ b/packages/components/src/avatar/avatar.tsx @@ -0,0 +1,83 @@ +import { useMemo } from 'react'; +import React from 'react'; + +import { + TextStyleProps, + ThemingProps, + omitThemingProps, + splitTextProps, +} from '@ficus-ui/style-system'; + +import { + type NativeFicusProps, + ficus, + forwardRef, + useMultiStyleConfig, +} from '../system'; +import { AvatarImage } from './avatar-image'; +import { AvatarOptions } from './avatar.types'; + +export interface AvatarProps + extends NativeFicusProps<'View'>, + TextStyleProps, + 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); + + 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] + ); + + 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/avatar/avatar.types.tsx b/packages/components/src/avatar/avatar.types.tsx new file mode 100644 index 00000000..5452299e --- /dev/null +++ b/packages/components/src/avatar/avatar.types.tsx @@ -0,0 +1,5 @@ +export interface AvatarOptions { + name: string; + getInitials?(name: string): string; + src?: string; +} diff --git a/packages/components/src/avatar/index.tsx b/packages/components/src/avatar/index.tsx new file mode 100644 index 00000000..a7f209e8 --- /dev/null +++ b/packages/components/src/avatar/index.tsx @@ -0,0 +1,3 @@ +export { Avatar } from './avatar'; +export { AvatarGroup } from './avatar-group'; +export { AvatarBadge } from './avatar-badge'; diff --git a/packages/components/src/box/index.tsx b/packages/components/src/box/index.tsx index 535c6da0..90a14870 100644 --- a/packages/components/src/box/index.tsx +++ b/packages/components/src/box/index.tsx @@ -25,3 +25,9 @@ export const Box = forwardRef((props, ref) => { } return ; }); + +export const Circle = ficus('View', { + baseStyle: { + borderRadius: 'full', + }, +}); diff --git a/packages/components/src/image/index.tsx b/packages/components/src/image/index.tsx index a92e34da..77606f91 100644 --- a/packages/components/src/image/index.tsx +++ b/packages/components/src/image/index.tsx @@ -1,12 +1,27 @@ import { type NativeFicusProps, ficus, forwardRef } from '../system'; interface ImageOptions { + /** + * The React native `source.uri` property + */ src?: string; } export interface ImageProps extends NativeFicusProps<'Image'>, ImageOptions {} export const Image = forwardRef((props, ref) => { - const { src, ...rest } = props; - return ; + const { src, source: sourceProp, ...rest } = props; + + const getSourceProp = () => { + if (typeof sourceProp === 'number') { + return sourceProp; + } + + return { + uri: src, + ...sourceProp, + }; + }; + + return ; }); diff --git a/packages/components/src/image/use-image.tsx b/packages/components/src/image/use-image.tsx new file mode 100644 index 00000000..518270e3 --- /dev/null +++ b/packages/components/src/image/use-image.tsx @@ -0,0 +1,52 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { Image as RNImage } from 'react-native'; + +export interface UseImageOptions { + /** + * The React Native `source.uri` property + */ + src?: string; +} + +type Status = 'loading' | 'failed' | 'pending' | 'loaded'; + +/** + * Custom hook to track the loading status of an image. + * + * @returns The current loading status of the image. + * + * @example + * ```tsx + * const status = useImage({ src: 'https://example.com/image.jpg' }); + * console.log(status); // "loading" | "loaded" | "failed" | "pending" + * ``` + */ +export function useImage({ src }: UseImageOptions) { + const [status, setStatus] = useState('pending'); + + useEffect(() => { + setStatus(src ? 'loading' : 'pending'); + }, [src]); + + const load = useCallback(() => { + if (!src) return; + + RNImage.getSize( + src, + /** + * On success + */ + () => setStatus('loaded'), + () => setStatus('failed') + ); + }, [src]); + + useEffect(() => { + if (status === 'loading') { + load(); + } + }, [status, load]); + + return status; +} diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 73d27385..7f75ed27 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -18,5 +18,6 @@ export * from './section-list'; export * from './flash-list'; export * from './modal'; export * from './draggable-modal'; +export * from './avatar'; export { FicusProvider, type FicusProviderProps } from './system'; diff --git a/packages/style-system/src/config/position.ts b/packages/style-system/src/config/position.ts index ef8e4d07..211e5f26 100644 --- a/packages/style-system/src/config/position.ts +++ b/packages/style-system/src/config/position.ts @@ -5,11 +5,11 @@ import { ResponsiveValue } from '../utils/types'; export const position: Config = { position: true, top: t.space('top'), - right: t.space('top'), - bottom: t.space('top'), - left: t.space('top'), - zIndex: t.space('top'), - elevation: t.space('top'), + right: t.space('right'), + bottom: t.space('bottom'), + left: t.space('left'), + zIndex: true, + elevation: t.space('elevation'), }; export interface PositionProps { diff --git a/packages/theme/src/components/avatar.ts b/packages/theme/src/components/avatar.ts new file mode 100644 index 00000000..4311aee7 --- /dev/null +++ b/packages/theme/src/components/avatar.ts @@ -0,0 +1,112 @@ +import { + createMultiStyleConfigHelpers, + defineStyle, +} from '@ficus-ui/style-system'; + +import { darkenColor, lightenColor, randomColorFromString } from '../utils'; + +const avatarParts = [ + 'label', + 'badge', + 'container', + 'excessLabel', + 'group', +] as const; + +const { definePartsStyle, defineMultiStyleConfig } = + createMultiStyleConfigHelpers(avatarParts); + +const baseStyleBagde = defineStyle({ + bg: 'green.400', + borderRadius: '100%', + borderWidth: 2, + borderColor: 'white', + borderStyle: 'solid', +}); + +const baseStyleExcessLabel = defineStyle({ + bg: 'gray.300', + fontSize: 'sm', + width: 30, + height: 30, +}); + +const baseStyleContainer = defineStyle((props) => { + const { name, colorScheme: c } = props; + + const bg = c + ? `${c}.300` + : name + ? lightenColor(randomColorFromString(name), 30) + : 'gray.300'; + return { + bg, + borderColor: 'white', + verticalAlign: 'center', + width: 30, + height: 30, + }; +}); + +const baseStyleLabel = defineStyle((props) => { + const { name, colorScheme: c } = props; + + const color = c + ? `${c}.800` + : name + ? darkenColor(randomColorFromString(name), 40) + : 'gray.800'; + + return { + color, + }; +}); + +const baseStyle = definePartsStyle((props) => { + return { + badge: baseStyleBagde, + excessLabel: baseStyleExcessLabel, + container: baseStyleContainer(props), + label: baseStyleLabel(props), + }; +}); + +const AVATAR_BADGE_RATIO = 3; +function getSize(size: number) { + return definePartsStyle({ + container: { + width: size, + height: size, + }, + excessLabel: { + width: size, + height: size, + fontSize: size / 4, + }, + label: { + fontSize: size / 3, + }, + badge: { + width: size / AVATAR_BADGE_RATIO, + height: size / AVATAR_BADGE_RATIO, + top: size - size / AVATAR_BADGE_RATIO, + right: 0, + }, + }); +} + +const sizes = { + xs: getSize(30), + sm: getSize(40), + md: getSize(50), + lg: getSize(70), + xl: getSize(80), +}; + +export const avatarTheme = defineMultiStyleConfig({ + baseStyle, + sizes, + defaultProps: { + size: 'md', + }, +}); diff --git a/packages/theme/src/components/index.ts b/packages/theme/src/components/index.ts index 266a7dfe..d846fea8 100644 --- a/packages/theme/src/components/index.ts +++ b/packages/theme/src/components/index.ts @@ -1,10 +1,14 @@ +import { avatarTheme } from './avatar'; import { badgeTheme } from './badge'; 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, Slider: sliderTheme, }; 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); diff --git a/packages/theme/src/utils/color.ts b/packages/theme/src/utils/color.ts index 3f6568f1..3fa619b6 100644 --- a/packages/theme/src/utils/color.ts +++ b/packages/theme/src/utils/color.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-bitwise */ import { transparentize as transparentize2k } from 'color2k'; type Dict = Record; @@ -61,3 +62,58 @@ function isValidColor(value: string): boolean { // TODO return true; } + +export function randomColorFromString(str: string) { + let hash = 0; + if (str.length === 0) return hash.toString(); + for (let i = 0; i < str.length; i += 1) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + hash = hash & hash; + } + let color = '#'; + for (let j = 0; j < 3; j += 1) { + const value = (hash >> (j * 8)) & 255; + 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) + ); +};