Skip to content

Instantly share code, notes, and snippets.

@jhesgodi
Last active October 7, 2022 10:00
Show Gist options
  • Save jhesgodi/89a51c2d1a053df09c85146799536625 to your computer and use it in GitHub Desktop.
Save jhesgodi/89a51c2d1a053df09c85146799536625 to your computer and use it in GitHub Desktop.
Variant Selector Lib

Variant Selector

Given a list of properties with values (aka Props), and

Given a list of conditions derivated from evaluating properties values (aka variants),

Then compute and output of the properties values that match given conditions (aka Variants that match Props values)

Example: Compute conditional classes use in TSX (tailwindcss)

Given a Button component with props

  type ButtonProps = {
    disabled?: boolean;
    color: "blue" | "red";
    size: "small" | "large";
    loading: boolean;
  }

Compute basic styles

Apply the following classes according to each property value:

color=blue              size=small          disabled=true
--------------------- | ----------------- | ----------------------------
bg-blue-100 text-blue   text-sm py-5 px-2   cursor-not-allowed opacity-5

Regularly one end up writing unmantainable and verbose code, of the sort:

const Button = (props: ButtonProps) => {
  const colorStyles = props.color === "blue" ? "bg-blue-100 text-blue" : props.color === "red" ? "bg-red-100 text-red" : "";
  const sizeStyles = props.size === "small" ? "text-sm py-5 px-2" : "text-lg py-8 px-4";
  const disabledStyles = props.disabled ? "cursor-not-allowed opacity-5" : "";
  
  return <button className={colorStyles + sizeStyles + disabledStyles} ... />
}

Sometimes you do it inline within className={...} or maybe a bit better by creating yourself a mapping or something, but the fact this interpolations always end up messy, leaving your code hard to read and mantain later on.

Instead, with this approach you can express those conditions in a declarative way:

const variants: Variants<ButtonProps> = {
  "color.blue": "bg-blue-100 text-blue",
  "size.small": "text-sm py-5 px-2",
  disabled: "cursor-not-allowed opacity-5",
};

const Button = (props: ButtonProps) => {
  const [classNames, matches] = vsx(variants, props);
  return <button className={classNames} ... />
}

Usage:

<Button color="blue" size="small" disabled /> // className="bg-blue-100 text-blue text-sm py-5 px-2 cursor-not-allowed opacity-5"

Compute more complex styles

const variants: Variants<ButtonProps> = {
  "color.red.disabled.false": "bg-red-300 bg-red-500 text-red cursor-pointer"

  // can also just compute the condition
  "disabled.false.size.large.color.red": undefined,
};

const Button = (props: ButtonProps) => {
  const [classNames, matches] = vsx(variants, props);
  
  if (matches.has("disabled.true.size.small.color.red")) {
    // this is how to check the criteria was met
    // now you can do some custom logic here
  }
  
  return <button className={classNames} ... />
}

Usage:

<Button color="red" size="large" disabled={false} /> // className="bg-red-300 bg-red-500 text-red cursor-pointer"

Hooks

  • $nil: right side value is null or undefined
  • $all: no conditions always included (can only be included at the root)
  • $none -> a valid variant was not found ie:
const variants = {
  "color.red": "red",
  "color.blue": "blue",
  "color.$none:: "magenta" // <-- if props.color is neither "red" or "blue"
}

Boolean shortcut

  • the right side value is always thruthy/falsy, objects and functions are casted to boolean as well ie:
const variants = {
  "disabled": "disabled", // <-- equivalent to "disabled.true"
}

Property forwarding

  • when found props.className and props.classNames are forwarded ie:
const [classNames] = vsx({}, { className: "my-class" }); // classNames="my-class"

// can customised by using the 3rd param -> vsx(variants: Record, props: Record, forwardedKeys: string[]);
const [classNames] = vsx({}, { classes: "class-a class-b" }, ["classes"]); // classNames="class-a class-b"
import { Variants } from "./types";
import { vsx } from "./vsx";
type Props = {
loading?: boolean;
disabled?: boolean;
color: "blue" | "red";
size: "small" | "large";
};
const variants: Variants<Props> = {
"color.blue": "bg-blue-100 text-blue",
"size.small": "text-sm py-5 px-2",
"size.large": "text-sm py-5 px-2",
disabled: "cursor-not-allowed opacity-5",
};
const props: Props = {
color: "blue",
size: "large",
disabled: true,
loading: false,
};
const [className, matches] = vsx(variants, props);
type Hooks1 = "$nil" | "$notnil" | "$none";
type Hooks2 = "true" | "false";
type Thruthy = boolean | object | Function;
type Index = string | number;
type NestedKeyOf<P extends object, T = Required<P>> = {
[Key in keyof T & Index]: T[Key] extends Thruthy
? Key | `${Key}.${Hooks2}` | `${Key}.${Hooks2}.${NestedKeyOf<Omit<T, Key>>}`
:
| `${Key}.${Hooks1}`
| `${Key}.${T[Key] extends Thruthy
? never
:
| (T[Key] & Index)
| `${T[Key] & Index}.${NestedKeyOf<Omit<T, Key>>}`}`;
}[keyof T & Index];
export type Variants<T extends Record<string, any>> = {
[key in NestedKeyOf<T> | "$all"]?: any;
};
const split = (selector: string): string[] => selector.split(".");
const chunk = <T>(input: T[], size: number = 2): [T, T][] => {
const list = backfill(input, size, true);
const length = Math.ceil(list.length / size);
return Array.from({ length }, () => list.splice(0, size)) as [T, T][];
};
const backfill = <T, F>(input: (T | F)[], size: number, fill: F): (T | F)[] => {
const length = Math.ceil(input.length / size) * size;
return Array.from({ length }, (_, idx) => input[idx] ?? fill);
};
const isNil = (value: unknown): value is null | undefined =>
value === null || value === undefined;
const comparators = {
$nil: (_: string, b: unknown) => isNil(b),
$notnil: (_: string, b: unknown) => !isNil(b),
thruthy: (a: string, b: unknown) => String(a) === "true",
default: (a: string, b: unknown) => String(a) === String(b),
};
const hooks = ["$none", "$nil", "$notnil"];
const forwardPropNames = ["className", "classNames"];
type TProps = {
[key: string]: any;
className?: string;
};
export const vsx = <TVariants extends Record<string, any>>(
variants: TVariants,
props: TProps,
forwardProps: string[] = forwardPropNames
): [string, Map<keyof TVariants, any>] => {
const chunks: string[] = [];
const matches = new Map<keyof TVariants, any>();
const heads: string[] = [];
const local = Object.entries(variants).filter(
([selector]) => !selector.includes("$none")
);
const global = Object.entries(variants).filter(([selector]) =>
selector.includes("$none")
);
local.forEach(([selector, variant]) => {
const fragments = chunk(split(selector), 2);
const isMatching = fragments.every(([propName, valueOrHook]) => {
const isHook = hooks.includes(valueOrHook);
const isThruthy = typeof props[propName] === "object";
const comparator = isHook
? valueOrHook
: isThruthy
? "thruthy"
: "default";
return comparators[comparator](valueOrHook, props[propName]);
});
if (isMatching) {
chunks.push(variant);
matches.set(selector as keyof TVariants, variant);
heads.push(selector.replace(/([^\.]+$)/, ""));
}
});
global.forEach(([selector, variant]) => {
const head = selector.replace(/([^\.]+$)/, "");
const hook = selector.match(/([^\.]+$)/)?.[0];
if (heads.includes(head) && hook === "$none") {
return;
}
if (!heads.includes(head) && hook === "$none") {
chunks.push(variant);
return;
}
});
variants.$all && chunks.unshift(variants.$all);
forwardProps &&
forwardProps.forEach(
(propName) => !isNil(props[propName]) && chunks.unshift(props[propName])
);
return [Array.from(new Set(chunks)).join(" "), matches];
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment