diff --git a/example/src/AppRoot.tsx b/example/src/AppRoot.tsx index cd083e6a..02e64eb6 100644 --- a/example/src/AppRoot.tsx +++ b/example/src/AppRoot.tsx @@ -24,7 +24,7 @@ const Drawer = createDrawerNavigator(); const AppRoot = () => { return ( - + { - const tailwind = useTheme(); return ( - - + + Scheduled - - - Assigned - - } />} - > - On Progress - - - Confirmed - - } />} - > - Cancelled - - } />} - > - Completed - - - - Done - + + + <>Badge + + + ); }; diff --git a/example/tsconfig.json b/example/tsconfig.json index 84fd5b6a..137260b8 100644 --- a/example/tsconfig.json +++ b/example/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "baseUrl": ".", "paths": { "@adaptui/react-native-tailwind": ["../src/index"] } diff --git a/src/components/badge-new/Badge.tsx b/src/components/badge-new/Badge.tsx new file mode 100644 index 00000000..b7c18925 --- /dev/null +++ b/src/components/badge-new/Badge.tsx @@ -0,0 +1,18 @@ +import * as React from "react"; +import { View } from "react-native"; + +import { BadgeNewProps, useBadgeProps } from "./BadgeProps"; +import { BadgeText } from "./BadgeText"; +import { BadgeWrapper } from "./BadgeWrapper"; + +export const BadgeNew = React.forwardRef((props, ref) => { + const { wrapperProps, textProps } = useBadgeProps(props); + + return ( + + + + ); +}); + +BadgeNew.displayName = "BadgeNew"; diff --git a/src/components/badge-new/BadgeProps.tsx b/src/components/badge-new/BadgeProps.tsx new file mode 100644 index 00000000..73cee140 --- /dev/null +++ b/src/components/badge-new/BadgeProps.tsx @@ -0,0 +1,66 @@ +import { useMemo } from "react"; + +import { getComponentProps, RenderProp } from "../../utils/system"; + +import { BadgeTextProps } from "./BadgeText"; +import { + BadgeUIState, + BadgeUIStateProps, + useBadgeUIState, +} from "./BadgeUIState"; +import { BadgeWrapperProps } from "./BadgeWrapper"; + +const componentMap = { + BadgeWrapper: "wrapperProps", + BadgeText: "textProps", +}; + +export function useBadgeProps(props: BadgeNewProps): BadgePropsReturn { + let { size, themeColor, variant, children, ...restProps } = props; + + const uiState = useBadgeUIState({ + size, + themeColor, + variant, + }); + let uiProps: BadgeUIProps = useMemo(() => ({ ...uiState }), [uiState]); + + const { componentProps, finalChildren } = getComponentProps( + componentMap, + children, + uiProps, + ); + const _finalChildren = componentProps?.textProps?.children || finalChildren; + const wrapperProps: BadgeWrapperProps = useMemo( + () => ({ + ...uiProps, + ...restProps, + ...componentProps.wrapperProps, + }), + [componentProps.wrapperProps, restProps, uiProps], + ); + + const textProps: BadgeTextProps = useMemo( + () => ({ + ...uiProps, + ...componentProps.textProps, + children: _finalChildren, + }), + [componentProps.textProps, uiProps, _finalChildren], + ); + + return { uiProps, wrapperProps, textProps }; +} + +export type BadgeUIProps = BadgeUIState & {}; + +export type BadgeNewProps = Omit & + BadgeUIStateProps & { + children?: RenderProp; + }; + +export type BadgePropsReturn = { + uiProps: BadgeUIProps; + wrapperProps: BadgeWrapperProps; + textProps: BadgeTextProps; +}; diff --git a/src/components/badge-new/BadgeText.tsx b/src/components/badge-new/BadgeText.tsx new file mode 100644 index 00000000..46c06934 --- /dev/null +++ b/src/components/badge-new/BadgeText.tsx @@ -0,0 +1,46 @@ +import { + AdaptText, + AdaptTextOptions, + useAdaptText, +} from "../../primitives/text"; +import { useTheme } from "../../theme"; +import { cx } from "../../utils"; +import { + As, + createComponentType, + createElement, + createHook, + Props, +} from "../../utils/system"; + +import { BadgeUIProps } from "./BadgeProps"; + +export const useBadgeText = createHook( + ({ size, themeColor, variant, ...props }) => { + const badgeStyles = useTheme("badge"); + const className = cx( + size ? badgeStyles.size[size]?.text : "", + themeColor && variant + ? badgeStyles.themeColor[themeColor]?.[variant]?.text + : "", + props.className, + ); + + props = useAdaptText({ ...props, className }); + + return props; + }, +); + +export const BadgeText = createComponentType(props => { + const htmlProps = useBadgeText(props); + + return createElement(AdaptText, htmlProps); +}, "BadgeText"); + +export type BadgeTextOptions = + AdaptTextOptions & Partial & {}; + +export type BadgeTextProps = Props< + BadgeTextOptions +>; diff --git a/src/components/badge-new/BadgeUIState.ts b/src/components/badge-new/BadgeUIState.ts new file mode 100644 index 00000000..44b884f0 --- /dev/null +++ b/src/components/badge-new/BadgeUIState.ts @@ -0,0 +1,32 @@ +import { GetThemeValue } from "../../utils/global-types"; + +export const useBadgeUIState = (props: BadgeUIStateProps): BadgeUIState => { + const { size = "md", themeColor = "base", variant = "solid" } = props; + + return { size, themeColor, variant }; +}; + +export type BadgeUIState = { + /** + * How large should the badge be? + * + * @default md + */ + size: keyof GetThemeValue<"badge", "size">; + /** + * How the badge should be themed? + * + * @default base + */ + themeColor: keyof GetThemeValue<"badge", "themeColor">; + /** + * How the badge should look? + * + * @default solid + */ + variant: keyof GetThemeValue<"badge", "themeColor", "base">; +}; + +export type BadgeUIStateProps = Partial< + Pick +> & {}; diff --git a/src/components/badge-new/BadgeWrapper.tsx b/src/components/badge-new/BadgeWrapper.tsx new file mode 100644 index 00000000..8a975722 --- /dev/null +++ b/src/components/badge-new/BadgeWrapper.tsx @@ -0,0 +1,43 @@ +import { Box, BoxOptions, useBox } from "../../primitives/box"; +import { useTheme } from "../../theme"; +import { cx } from "../../utils"; +import { + As, + createComponentType, + createElement, + createHook, + Props, +} from "../../utils/system"; + +import { BadgeUIProps } from "./BadgeProps"; + +export const useBadgeWrapper = createHook( + ({ size, themeColor, variant, ...props }) => { + const badgeStyles = useTheme("badge"); + const className = cx( + badgeStyles.baseContainer, + size ? badgeStyles.size[size]?.container : "", + themeColor && variant + ? badgeStyles.themeColor[themeColor]?.[variant]?.container + : "", + props.className, + ); + + props = useBox({ ...props, className }); + + return props; + }, +); + +export const BadgeWrapper = createComponentType(props => { + const htmlProps = useBadgeWrapper(props); + + return createElement(Box, htmlProps); +}, "BadgeWrapper"); + +export type BadgeWrapperOptions = BoxOptions & + Partial & {}; + +export type BadgeWrapperProps = Props< + BadgeWrapperOptions +>; diff --git a/src/components/badge-new/index.ts b/src/components/badge-new/index.ts new file mode 100644 index 00000000..a15db693 --- /dev/null +++ b/src/components/badge-new/index.ts @@ -0,0 +1,5 @@ +export * from "./Badge"; +export * from "./BadgeProps"; +export * from "./BadgeText"; +export * from "./BadgeUIState"; +export * from "./BadgeWrapper"; diff --git a/src/components/index.ts b/src/components/index.ts index 14989347..f1d53ee9 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,6 +1,7 @@ export * from "./avatar"; export * from "./avatar-group"; export * from "./badge"; +export * from "./badge-new"; export * from "./button"; export * from "./checkbox"; export * from "./circular-progress"; diff --git a/src/primitives/box/Box.tsx b/src/primitives/box/Box.tsx index d97355a5..fd8ec9a6 100644 --- a/src/primitives/box/Box.tsx +++ b/src/primitives/box/Box.tsx @@ -1,10 +1,32 @@ -import { View, ViewProps as RNViewProps } from "react-native"; +import { View } from "react-native"; -import { createComponent } from "../../utils/createComponent"; -import type { Dict } from "../../utils/types"; +import { useTheme } from "../../theme"; +import { styleAdapter } from "../../utils"; +import { + As, + ComponentOptions, + createComponentType, + createElement, + createHook, + Props, +} from "../../utils/system"; -export type LibraryBoxProps = Dict; +export const useBox = createHook( + ({ __TYPE__, className, ...props }) => { + const tailwind = useTheme(); + const style = [tailwind.style(className), styleAdapter(props.style)]; -export type BoxProps = RNViewProps & LibraryBoxProps; + return { ...props, style }; + }, +); -export const Box = createComponent(View, { shouldMemo: true }); +export const Box = createComponentType(props => { + const htmlProps = useBox(props); + return createElement(View, htmlProps); +}, "Box"); + +export type BoxOptions = ComponentOptions & { + className?: string; +}; + +export type BoxProps = Props>; diff --git a/src/primitives/text/AdaptText.tsx b/src/primitives/text/AdaptText.tsx new file mode 100644 index 00000000..dc67459a --- /dev/null +++ b/src/primitives/text/AdaptText.tsx @@ -0,0 +1,29 @@ +import { + As, + createComponent, + createElement, + createHook, + Props, +} from "../../utils/system"; +import { BoxOptions, useBox } from "../box"; + +import { Text, TextOptions, useText } from "./Text"; + +export const useAdaptText = createHook(props => { + props = useText(props); + props = useBox(props); + + return props; +}); + +export const AdaptText = createComponent(props => { + const htmlProps = useAdaptText(props); + return createElement(Text, htmlProps); +}); + +export type AdaptTextOptions = TextOptions & + BoxOptions; + +export type AdaptTextProps = Props< + AdaptTextOptions +>; diff --git a/src/primitives/text/Text.tsx b/src/primitives/text/Text.tsx index 8eca3051..24bb8f5d 100644 --- a/src/primitives/text/Text.tsx +++ b/src/primitives/text/Text.tsx @@ -1,10 +1,23 @@ -import { Text as RNText, TextProps as RNTextProps } from "react-native"; +import { Text as RNText } from "react-native"; -import { createComponent } from "../../utils/createComponent"; -import type { Dict } from "../../utils/types"; +import { + As, + createComponent, + createElement, + createHook, + Options, + Props, +} from "../../utils/system"; -export type LibraryTextProps = Dict; +export const useText = createHook(props => { + return props; +}); -export type TextProps = RNTextProps & LibraryTextProps; +export const Text = createComponent(props => { + const htmlProps = useText(props); + return createElement(RNText, htmlProps); +}); -export const Text = createComponent(RNText, { shouldMemo: true }); +export type TextOptions = Options; + +export type TextProps = Props>; diff --git a/src/primitives/text/index.ts b/src/primitives/text/index.ts index 22e10b67..49e54ba1 100644 --- a/src/primitives/text/index.ts +++ b/src/primitives/text/index.ts @@ -1 +1,2 @@ +export * from "./AdaptText"; export * from "./Text"; diff --git a/src/theme/index.ts b/src/theme/index.ts index 2edd280c..69db159d 100644 --- a/src/theme/index.ts +++ b/src/theme/index.ts @@ -1 +1,2 @@ export * from "./context"; +export * from "./defaultTheme"; diff --git a/src/utils/global-types.ts b/src/utils/global-types.ts new file mode 100644 index 00000000..31b6c81d --- /dev/null +++ b/src/utils/global-types.ts @@ -0,0 +1,91 @@ +import { DefaultTheme } from "../theme"; + +// https://stackoverflow.com/questions/60795256/typescript-type-merging +// https://dev.to/svehla/typescript-how-to-deep-merge-170c + +/** + * Take two objects T and U and create the new one with uniq keys for T a U objectI + * helper generic for `DeepMergeTwoTypes` + */ +type GetObjDifferentKeys = Omit & Omit; + +/** + * Take two objects T and U and create the new one with the same objects keys + * helper generic for `DeepMergeTwoTypes` + */ +type GetObjSameKeys = Omit>; + +type Merge = + // non shared keys are optional + Partial> & { + // shared keys are recursively resolved by `DeepMergeTwoTypes<...>` + [K in keyof GetObjSameKeys]: DeepMerge; + }; + +// it merge 2 static types and try to avoid of unnecessary options (`'`) +/** + * @template T source object + * @template U target object + * + * @description Deep merge two theme objects + */ +export type DeepMerge = + // check if generic types are arrays and unwrap it and do the recursion + [T, U] extends [(infer TItem)[], (infer UItem)[]] + ? DeepMerge[] + : // check if generic types are objects + [T, U] extends [{ [key: string]: unknown }, { [key: string]: unknown }] + ? Merge + : [T, U] extends [ + { [key: string]: unknown } | undefined, + { [key: string]: unknown } | undefined, + ] + ? Merge, NonNullable> | undefined + : T | U; + +interface _ComponentDefaultTheme { + components: DefaultTheme; +} + +declare const _brand: unique symbol; + +export interface Theme extends _ComponentDefaultTheme {} +/** + * @template T default theme + * @template U user theme + * + * @description Safely Deep merges default theme with user theme + */ +export type MergeTheme = DeepMerge< + T, + U extends { [x: string]: any } ? U : {} +>; + +type Brand = Type & { [_brand]: Name }; + +type Comps = Theme["components"]; + +/** + * @template C component name + * @template K theme key + * @template L theme key + * @template M theme key + * @template N theme key + */ +export type GetThemeValue< + C extends keyof Comps, + K extends keyof Comps[C] = Brand, + L extends keyof Comps[C][K] = Brand, + M extends keyof Comps[C][K][L] = Brand, + N extends keyof Comps[C][K][L][M] = Brand, +> = [C, K, L, M, N] extends [string, Brand, Brand, Brand, Brand] + ? Comps[C] + : [C, K, L, M, N] extends [string, string, Brand, Brand, Brand] + ? Comps[C][K] + : [C, K, L, M, N] extends [string, string, string, Brand, Brand] + ? Comps[C][K][L] + : [C, K, L, M, N] extends [string, string, string, string, Brand] + ? Comps[C][K][L][M] + : [C, K, L, M, N] extends [string, string, string, string, string] + ? Comps[C][K][L][M][N] + : never; diff --git a/src/utils/system.tsx b/src/utils/system.tsx new file mode 100644 index 00000000..796db6e1 --- /dev/null +++ b/src/utils/system.tsx @@ -0,0 +1,261 @@ +import React, { + ComponentPropsWithRef, + ElementType, + forwardRef, + HTMLAttributes, + ReactElement, + ReactNode, + RefAttributes, +} from "react"; + +import { Dict } from "./types"; + +export function createComponent( + render: (props: Props) => ReactElement, +) { + const Role = (props: Props, ref: React.Ref) => + render({ ref, ...props }); + return forwardRef(Role) as unknown as Component; +} + +export function createComponentType( + render: (props: Props) => React.ReactElement, + type: string, +) { + const Role = (props: Props, ref: React.Ref) => + render({ ref, ...props, __TYPE__: type }); + + const Component = forwardRef(Role) as unknown as ComponentProps; + Component.defaultProps = { __TYPE__: type }; + + return Component; +} + +export function createElement(Type: ElementType, props: HTMLProps) { + const { as: As, wrapElement, ...rest } = props; + let element: ReactElement; + if (As && typeof As !== "string") { + element = ; + } else if (isRenderProp(props.children)) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { children, ...otherProps } = rest; + element = props.children(otherProps) as ReactElement; + } else if (As) { + element = ; + } else { + element = ; + } + if (wrapElement) { + return wrapElement(element); + } + return element; +} + +function isRenderProp(children: any): children is AriakitRenderProp { + return typeof children === "function"; +} + +export function createHook( + useProps: (props: Props) => HTMLProps, +) { + const useRole = (props: Props = {} as Props) => { + const htmlProps = useProps(props); + const copy = {} as typeof htmlProps; + for (const prop in htmlProps) { + if (hasOwnProperty(htmlProps, prop) && htmlProps[prop] !== undefined) { + copy[prop] = htmlProps[prop]; + } + } + return copy; + }; + return useRole as Hook; +} + +/** + * Checks whether `prop` is an own property of `obj` or not. + */ +export function hasOwnProperty( + object: T, + prop: keyof any, +): prop is keyof T { + return Object.prototype.hasOwnProperty.call(object, prop); +} + +export type ComponentProps = Component & { + defaultProps?: { __TYPE__: string }; +}; + +export type ComponentOptions = Options & { + __TYPE__?: string; +}; + +/** + * A component hook that supports the `as` prop and the `children` prop as a + * function. + * @template O Options + * @example + * type ButtonHook = Hook>; + */ +export type Hook = { + >( + props?: Omit & Omit>, keyof O> & Options, + ): HTMLProps>; + displayName?: string; +}; + +/** + * Props with the `as` prop. + * @template T The `as` prop + * @example + * type ButtonOptions = Options<"button">; + */ +export type Options = { as?: T }; + +/** + * Options & HTMLProps + * @template O Options + * @example + * type ButtonProps = Props>; + */ +export type Props = O & HTMLProps; + +export type Component = { + ( + props: Omit & + Omit>, keyof O> & + Required>, + ): JSX.Element | null; + (props: Props): JSX.Element | null; + displayName?: string; +}; + +/** + * The `as` prop. + * @template P Props + */ +export type As

= ElementType

; + +/** + * Props that automatically includes HTML props based on the `as` prop. + * @template O Options + * @example + * type ButtonHTMLProps = HTMLProps>; + */ +export type HTMLProps = { + wrapElement?: WrapElement; + children?: Children; + [index: `data-${string}`]: unknown; +} & Omit>, keyof O | "children">; + +/** + * The `wrapElement` prop. + */ +export type WrapElement = (element: ReactElement) => ReactElement; + +/** + * The `children` prop that supports a function. + * @template T Element type. + */ +export type Children = + | ReactNode + | AriakitRenderProp & RefAttributes>; + +/** + * Render prop type. + * @template P Props + * @example + * const children: RenderProp = (props) =>

; + */ +export type AriakitRenderProp

= (props: P) => ReactNode; + +/** + * Any object. + */ +export type AnyObject = Record; + +// Function assertions +export function isFunction(value: any): value is Function { + return typeof value === "function"; +} + +export function runIfFnChildren( + valueOrFn: T, + ...args: U[] +): React.ReactNode | React.ReactNode[] { + if (!isFunction(valueOrFn)) { + return valueOrFn as unknown as React.ReactNode; + } + + if (valueOrFn(...args).type.toString() !== "Symbol(react.fragment)") { + return [valueOrFn(...args)]; + } + + return valueOrFn(...args).props.children; +} + +/** + * Gets only the valid children of a component, + * and ignores any nullish or falsy child. + * + * @param children the children + */ +export function getValidChildren( + children: React.ReactNode | React.ReactNode[], +) { + return React.Children.toArray(children as React.ReactNode).filter(child => + React.isValidElement(child), + ); +} + +export const getComponentProps = ( + componentMaps: Dict, + children: RenderProp, + props: P, +) => { + const normalizedChildren = runIfFnChildren(children, props); + const validChildren = getValidChildren(normalizedChildren); + const componentProps: AnyObject = {}; + const finalChildren: React.ReactNode[] = []; + + if (validChildren.length > 0) { + validChildren.forEach(function (child) { + // @ts-ignore + if (componentMaps[child?.props?.__TYPE__]) { + // @ts-ignore + componentProps[componentMaps[child?.props?.__TYPE__]] = child.props; + } else { + finalChildren.push(child); + } + }); + } else { + finalChildren.push(normalizedChildren); + } + + return { componentProps, finalChildren }; +}; + +export function runIfFn( + component: RenderProp, + props: T, +): React.ReactNode { + return isFunction(component) ? component({ ...props }) : component; +} + +// Merge library & user prop +export const passProps = ( + component: RenderProp, + stateProps: S, + props?: T, +) => { + return React.isValidElement(component) + ? React.cloneElement(component, { + ...props, + // @ts-ignore + ...component?.props, + }) + : runIfFn(component, { ...stateProps, ...props }); +}; + +export declare type RenderProp = + | React.ReactNode + | AriakitRenderProp; diff --git a/tsconfig.json b/tsconfig.json index ecd929c0..2c998b00 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,9 +1,5 @@ { "compilerOptions": { - "baseUrl": "./", - "paths": { - "@adaptui/react-native-tailwind": ["./src/index"] - }, "target": "esnext", "lib": ["esnext"], "module": "esnext",