Skip to content

Instantly share code, notes, and snippets.

@antonmihaylov
Created July 11, 2022 08:58
Show Gist options
  • Save antonmihaylov/2d30f7ac6ac7e317ff9ef181de7e5118 to your computer and use it in GitHub Desktop.
Save antonmihaylov/2d30f7ac6ac7e317ff9ef181de7e5118 to your computer and use it in GitHub Desktop.
Tailwind component with variants wrapper
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 }
@antonmihaylov
Copy link
Author

antonmihaylov commented Jul 11, 2022

  • withClass

    • Forwards all props, combines props.className + the provided classes string, forwards ref, forwards otherProps to the element.
    • Usage:
      const Title = withClass('h3', 'text-sm font-medium text-red-800')
  • withClassVariants

    • Forwards all props, combines props.className + the provided classes string, fotwards ref, forwards otherProps to the element. Conditionally applies the classes from the variants object depending on the value of the prop with the same name as the object key (see example). Note that you can add boolean props by using true or false as variant keys (see isGhost in the example)
    • Usage:
const Action = withClassVariants('button', {
  classes: 'button flex-1', 
  variants: {
    color: { 
      danger: 'bg-red-600 hover:bg-red-700 focus:ring-red-500', 
      primary: 'bg-indigo-600 hover:bg-indigo-700 focus:ring-indigo-500', 
      secondary: 'border-gray-300  bg-white text-gray-700 hover:bg-gray-50 focus:ring-indigo-500', 
    }, 
    isGhost: {
       true: 'opacity-50'
    }
  }, 
  defaultVariants: { color: 'primary', }, 
})

...

<Action color="danger" isGhost />

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment