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 ;
+}
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)
+ );
+};