Created
July 11, 2022 08:58
-
-
Save antonmihaylov/2d30f7ac6ac7e317ff9ef181de7e5118 to your computer and use it in GitHub Desktop.
Tailwind component with variants wrapper
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import type { ClassValue } from 'clsx' | |
import clsx from 'clsx' | |
import defaults from 'lodash/fp/defaults' | |
import entries from 'lodash/fp/entries' | |
import flow from 'lodash/fp/flow' | |
import isBoolean from 'lodash/fp/isBoolean' | |
import isFunction from 'lodash/fp/isFunction' | |
import isNumber from 'lodash/fp/isNumber' | |
import isObject from 'lodash/fp/isObject' | |
import isString from 'lodash/fp/isString' | |
import keys from 'lodash/fp/keys' | |
import map from 'lodash/fp/map' | |
import omit from 'lodash/fp/omit' | |
import pickBy from 'lodash/fp/pickBy' | |
import type { | |
ComponentProps, | |
PropsWithChildren, | |
ForwardRefExoticComponent, | |
PropsWithoutRef, | |
RefAttributes, | |
} from 'react' | |
import React, { forwardRef } from 'react' | |
type ComponentOrIntrinsic = React.ComponentType | keyof JSX.IntrinsicElements | |
type ExtractProps<T extends ComponentOrIntrinsic> = T extends keyof JSX.IntrinsicElements | |
? JSX.IntrinsicElements[T] | |
: ComponentProps<T> | |
type ClassesOrFactory<T extends ComponentOrIntrinsic> = | |
| ClassValue | |
| Array<ClassValue> | |
| ((props: ExtractProps<T>) => ClassValue | Array<ClassValue>) | |
function getClassName(obj: unknown) { | |
return isObject(obj) && 'className' in obj | |
? (obj as { className?: ClassValue }).className | |
: undefined | |
} | |
function evaluateClassesOrFactory<T extends ComponentOrIntrinsic>( | |
classesOrFactory: ClassesOrFactory<T>, | |
props: ExtractProps<T>, | |
): Array<ClassValue> { | |
const arrayOrClasses = isFunction(classesOrFactory) ? classesOrFactory(props) : classesOrFactory | |
return Array.isArray(arrayOrClasses) ? arrayOrClasses : [arrayOrClasses] | |
} | |
type ExtractRefType<T extends ComponentOrIntrinsic> = T extends keyof JSX.IntrinsicElements | |
? JSX.IntrinsicElements[T] extends React.DetailedHTMLProps< | |
React.AnchorHTMLAttributes<infer R>, | |
unknown | |
> | |
? R | |
: never | |
: unknown | |
function withClass<T extends ComponentOrIntrinsic>( | |
component: T, | |
classesOrFactory: ClassesOrFactory<T>, | |
otherProps?: ExtractProps<T>, | |
) { | |
const Component = component as React.ComponentType | |
const wrapped = forwardRef<ExtractRefType<T>, PropsWithChildren<ExtractProps<T>>>( | |
({ children, ...props }, ref) => { | |
const finalProps = { ...otherProps, ...props } as ExtractProps<T> | |
const className = clsx( | |
getClassName(otherProps), | |
getClassName(props), | |
...evaluateClassesOrFactory(classesOrFactory, finalProps), | |
) | |
return ( | |
<Component | |
// eslint-disable-next-line @typescript-eslint/no-explicit-any | |
{...(finalProps as any)} | |
className={className} | |
ref={ref} | |
> | |
{children} | |
</Component> | |
) | |
}, | |
) | |
wrapped.displayName = typeof component === 'string' ? component : component.displayName | |
Object.assign(wrapped, component) | |
return wrapped | |
} | |
// | |
// With variants | |
// | |
type Variants = Record<string, Record<string, ClassValue | Array<ClassValue>> | undefined> | |
type Defaults<TV extends Variants> = Partial<VariantPropsNoDefaults<TV>> | undefined | |
function evaluateVariantsFactory<TV extends Variants, TD extends Defaults<TV>>( | |
variants: TV, | |
defaultVariants?: TD, | |
) { | |
function getVariantClasses( | |
key: string, | |
value: string | boolean | number, | |
): ClassValue | Array<ClassValue> { | |
const variantObj = variants[key] | |
if (!variantObj || !isObject(variantObj)) { | |
return undefined | |
} | |
// evaluate boolean in a truthy-falsy way | |
if ('true' in variantObj || 'false' in variantObj) { | |
value = !!value | |
} | |
return variantObj[String(value)] | |
} | |
const isKeyValuePairValid = (value: unknown, key: string): value is string | number | boolean => | |
key in variants && | |
(isBoolean(value) || isString(value) || isNumber(value)) && | |
!!getVariantClasses(key, value) | |
const pickVariantValues = flow( | |
pickBy(isKeyValuePairValid), | |
defaults(defaultVariants ?? {}), | |
entries, | |
map(([key, value]) => getVariantClasses(key, value as string | boolean | number) ?? []), | |
) | |
return (props: Record<string, unknown>): Array<ClassValue> => pickVariantValues(props) | |
} | |
type VariantPropsNoDefaults<TVariants extends Variants> = { | |
[key in keyof TVariants]: 'true' extends keyof TVariants[key] | |
? boolean | |
: 'false' extends keyof TVariants[key] | |
? boolean | |
: keyof TVariants[key] | |
} | |
type VariantProps< | |
TVariants extends Variants, | |
TDefaults extends Partial<VariantPropsNoDefaults<TVariants>> | undefined, | |
> = { | |
[key in keyof TDefaults & | |
keyof VariantPropsNoDefaults<TVariants>]?: VariantPropsNoDefaults<TVariants>[key] | |
} & { | |
[key in Exclude< | |
keyof VariantPropsNoDefaults<TVariants>, | |
keyof TDefaults | |
>]: VariantPropsNoDefaults<TVariants>[key] | |
} | |
interface WithClassVariantsInput< | |
T extends ComponentOrIntrinsic, | |
TVariants extends Variants, | |
TDefaults extends Defaults<TVariants>, | |
> { | |
classes?: ClassesOrFactory<T> | |
variants: TVariants | |
defaultVariants?: TDefaults | |
otherProps?: ExtractProps<T> | |
} | |
function withClassVariants< | |
T extends ComponentOrIntrinsic, | |
TVariants extends Variants, | |
TDefaults extends Partial<VariantPropsNoDefaults<TVariants>> | undefined, | |
>( | |
component: T, | |
input: WithClassVariantsInput<T, TVariants, TDefaults>, | |
): ForwardRefExoticComponent< | |
PropsWithoutRef<PropsWithChildren<VariantProps<TVariants, TDefaults> & ExtractProps<T>>> & | |
RefAttributes<T> | |
> { | |
const { defaultVariants, variants, otherProps, classes } = input | |
const Component = component as React.ComponentType | |
const cleanupProps = omit(keys(variants)) | |
const evaluateVariants = evaluateVariantsFactory(variants, defaultVariants) | |
const wrapped = forwardRef< | |
T, | |
PropsWithChildren<VariantProps<TVariants, TDefaults> & ExtractProps<T>> | |
>(({ children, ...props }, ref) => { | |
const finalProps = { ...otherProps, ...props } as ExtractProps<T> | |
const className = clsx( | |
getClassName(otherProps), | |
getClassName(props), | |
...evaluateClassesOrFactory(classes, finalProps), | |
...evaluateVariants(finalProps as Record<string, unknown>), | |
) | |
const cleanedProps = cleanupProps(finalProps) | |
return ( | |
<Component | |
// eslint-disable-next-line @typescript-eslint/no-explicit-any | |
{...(cleanedProps as any)} | |
className={className} | |
ref={ref} | |
> | |
{children} | |
</Component> | |
) | |
}) | |
wrapped.displayName = typeof component === 'string' ? component : component.displayName | |
Object.assign(wrapped, component) | |
return wrapped | |
} | |
export type { WithClassVariantsInput } | |
export { withClass, withClassVariants } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
withClass
const Title = withClass('h3', 'text-sm font-medium text-red-800')
withClassVariants