Skip to content

Instantly share code, notes, and snippets.

@edygar
Last active October 1, 2021 14:25
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save edygar/6e95d40b99d00d41b0e4c268d1c2c5a1 to your computer and use it in GitHub Desktop.
Save edygar/6e95d40b99d00d41b0e4c268d1c2c5a1 to your computer and use it in GitHub Desktop.
Tailwind classNames resolution

Tailwind classname resolver

A simple JavaScript utility for conditionally joining classNames together (using on classnames), but avoiding collisions on tailwind utilities. The right-most utility takes precedence over previous of the same property (eg.: border my-1 m-2 -> border m-1). This utility was inspired this tweet and it's under discussion on this thread

Usage

Here's a good example, given a component such as:

const SomeComponent = ({ className }) => 
  <div className={tw`border-4 ${className}`}>
    {/* … lots of other components */}
  </div>

Now the consumer can be sure the utility on className will be applied consistently:

<>
  <SomeComponent className="border-2" />
  <SomeComponent className="border-8" />
</>

Without the utility, the resulting className would end up in border-4 border-2 and border-4 border-8, both with border-4 (demo).

import cx from "classnames";
export interface MatcherConstructor {
(modifiers: string, candidate: string): RegExp | null;
}
const memo = new Map<string, string>();
const regexpMemo = new Map<string, RegExp>();
const regexp = (...args: Parameters<typeof String.raw>) => {
const pattern = String.raw(...args);
if (regexpMemo.has(pattern)) {
return regexpMemo.get(pattern)!;
}
const newRegexp = new RegExp(pattern);
regexpMemo.set(pattern, newRegexp);
return newRegexp;
};
const spacingRegExp = /[\s\t ]+/g;
const modifiersAndRestRegExp = /([^\s]*?)((?:[-\w]*|(?:::))*)$/;
const colorsMatcher =
"transparent|current|black|white|(?:(?:gray|red|yellow|pink|indigo|purple)-(?:50|(?:[1-9]00)))";
const percentageMatcher = "0|(?:[27]{0,1}5)|(?:[1-9]0)|100";
const dirsMatcher = "t|tr|r|br|b|bl|l|tl";
const directionsMatcher =
"bottom|center|left|left-bottom|left-top|right|right-bottom|right-top|top";
const fractionsUpTo4 = `((1/[2-4])|(2/[34])|(3/4))`;
const fractionsUpTo6 = `((1/([2-6]|12))|(2/[3-6])|(3/[4-6])|(4/[56])|(5/6))`;
const zTo12 = "([0-9]|(1[0-2]))";
const numbers = "([0-3](.5)?)|([4-9])|([1-9][24680])";
export const groups = [
"bg-(?:auto|cover|contain)",
"bg-(?:clip-border|clip-padding|clip-content|clip-text)",
"bg-(?:fixed|local|scroll)",
"bg-(?:origin-border|origin-padding|origin-content)",
"bg-(?:repeat|no-repeat|repeat-x|repeat-y|repeat-round|repeat-space)",
"isolate|isolation-auto",
"overscroll-(auto|contain|none|y-auto|y-contain|y-none|x-auto|x-contain|x-none)",
"static|fixed|absolute|relative|sticky",
`-?space-x-(${numbers}|px|reverse)`,
`-?space-y-(${numbers}|px|reverse)`,
`(in)?visible`,
`(ring-offset-[0248])|(shadow(-(sm|md|lg|xl|2xl|inner|none)))`,
`auto-cols-(auto|min|max|fr)`,
`auto-rows-(auto|min|max|fr)`,
`bg-(?:${colorsMatcher})`,
`bg-(?:${directionsMatcher})`,
`bg-(?:none|(?:gradient-to-(?:${dirsMatcher})))`,
`bg-opacity-(?:${percentageMatcher})`,
`block|contents|flex|flow-root|grid|hidden|(?:inline(?:-[block|flex|grid|table])?)|list-item|table|(?:table-(?:caption|cell|column|row|(?:(?:(?:header|footer|column)-)?group)))`,
`border-(?:${colorsMatcher})`,
`border-(solid|dashed|dotted|double|none)`,
`border-opacity-(?:${percentageMatcher})`,
`box-(?:border|content)`,
`clear-(?:left|right|both|none)`,
`col-(auto|((span|start)-(${zTo12}|auto|full)))`,
`content-(center|start|end|between|around|evenly)`,
`decoration-(?:slice|clone)`,
`divide-(?:${colorsMatcher})`,
`divide-(solid|dashed|dotted|double|none)`,
`divide-opacity-(?:${percentageMatcher})`,
`divide-x(-([0248]|reverse))?`,
`divide-y(-([0248]|reverse))?`,
`flex-((row(-reverse)?)|(col(-reverse)?))`,
`flex-(1|auto|initial|none)`,
`flex-(wrap(-reverse)?|nowrap)`,
`flex-grow(-0)?`,
`flex-shrink(-0)?`,
`float-(?:right|left|none)`,
`from-(?:${colorsMatcher})`,
`gap(-[xy])?(-(px|${numbers}))?`,
`grid-cols-(${zTo12}|none)`,
`grid-flow-(row|col|row-dense|col-dense)`,
`grid-rows-(${zTo12}|first|last|none)`,
`h-(full|screen|auto|${numbers}|((1/([2-6]|12))|()))`,
`items-(start|end|center|baseline|stretch)`,
`justify-(start|end|center|between|around|evenly)`,
`justify-items-(start|end|center|stretch)`,
`justify-self-(auto|start|end|center|stretch)`,
`max-w-(0|none|xs|((screen-)?(sm|md|lg|[23]?xl|full|none))|[4-7]xl|full|min|max|prose)`,
`min-w-(0|full|min|max)`,
`object-(?:${directionsMatcher})`,
`object-(?:contain|cover|fill|none|scale-down)`,
`opacity-(?:${percentageMatcher})`,
`order-(${zTo12}|first|last|none)`,
`overflow-(?:auto|hidden|visible|scroll|(?:[xy]-(?:auto|hidden|visible|scroll)))`,
`place-(start|end|center|stretch)`,
`place-content-(center|start|end|between|around|evenly|stretch)`,
`place-self-(auto|start|end|center|stretch)`,
`ring-(?:${colorsMatcher})`,
`ring-offset-(?:${colorsMatcher})`,
`ring-opacity-(?:${percentageMatcher})`,
`ring(-([0248]|inset))?`,
`row-(auto|((span|start)-(${zTo12}|auto|full)))`,
`self-(auto|start|end|center|stretch|baseline)`,
`to-(?:${colorsMatcher})`,
`via-(?:${colorsMatcher})`,
`w-(full|screen|min|max|auto|${numbers}|${fractionsUpTo6})`,
`z-(0|([1-5]0)|auto)`,
];
export function getGroupsMatcher(modifiers: string, candidate: string) {
for (const groupRules of groups) {
const matcher = regexp`(\s|^)${modifiers}(?:${groupRules})($|\s)`;
if (matcher.test(candidate)) {
return matcher;
}
}
return null;
}
export function getBoxMatcher(modifiers: string, candidate: string) {
let [matched, sign, property, mp, mpXY, insetXY, amount] =
candidate.match(
regexp`(?:^|\s)(-?)((m|p)(?:([xy])|(?:[trbl]))?)-(.*?)($|\s)`
) || [];
if (!matched) return null;
const rule = (...args: Parameters<typeof String.raw>) =>
regexp`(^|\s)${modifiers}(-)?(${String.raw(...args)})(-(.*?))($|\s)`;
switch (property) {
case "mx":
case "my":
case "px":
case "py":
return rule`${property}|${mp}`;
case "pt":
case "pb":
case "mt":
case "mb":
return rule`${property}|(${mp}y?)`;
case "pr":
case "pl":
case "mr":
case "ml":
return rule`${property}|(${mp}x?)`;
case "p":
case "m":
return rule`${property}`;
default:
return null;
}
}
export function getOffsetMatcher(modifiers: string, candidate: string) {
let [matched, sign, property, mp, mpXY, insetXY, amount] =
candidate.match(
regexp`(?:^|\s)(-?)(top|right|bottom|left|(inset(?:-(x|y))?))-(.*?)($|\s)`
) || [];
if (!matched) return null;
const rule = (...args: Parameters<typeof String.raw>) =>
regexp`(^|\s)${modifiers}(-)?(${String.raw(...args)})(-(.*?))($|\s)`;
switch (property) {
case "inset-x":
case "inset-y":
return rule`${property}|inset`;
case "top":
case "bottom":
return rule`${property}|(inset-y?)`;
case "right":
case "left":
return rule`${property}|(inset-x?)`;
case "inset":
return rule`${property}`;
default:
return null;
}
}
export function getRoundMatcher(modifiers: string, candidate: string) {
const [match, direction, y, x] =
candidate.match(
regexp`(?:^|\s)rounded(?:-(([tb]?)([lr]?)))?(-([^\s]+))?(?:$|\s)`
) || [];
if (!match) return null;
const rule = (...args: Parameters<typeof String.raw>) =>
regexp`(^|\s)${modifiers}rounded(-${String.raw(
...args
)})?(-([^-]*))?($|\s)`;
switch (direction) {
case "t":
case "b":
return rule`${y}`;
case "r":
case "l":
return rule`${x}`;
case "tr":
case "tl":
case "br":
case "bl":
return rule`(${y}${x}|${x}|${y})`;
default:
return rule`((?![tb]?[lr]?).*?)`;
}
}
export function getBorderWidthMatcher(modifiers: string, candidate: string) {
const [match, direction, y, x] =
candidate.match(
regexp`(?:^|\s)border(?:-([trbl]))?(?:-(?:[^\s]+))?(?:$|\s)`
) || [];
if (!match) return null;
const rule = (...args: Parameters<typeof String.raw>) =>
regexp`(^|\s)${modifiers}border(-${String.raw(
...args
)})?(-((?![tblr]-)[^-]*))?($|\s)`;
switch (direction) {
case "t":
case "b":
case "r":
case "l":
return rule`${direction}`;
default:
return rule`(?![tblr])`;
}
}
function matchCandidate(
matchers: MatcherConstructor[],
{
modifiers,
classNames,
candidate,
}: {
modifiers: string;
classNames: string;
candidate: string;
}
) {
for (const getMatcher of matchers) {
let matcher = getMatcher(modifiers, candidate);
if (matcher) {
console.log({
matcher: matcher.source,
classNames,
candidate,
modifiers,
result: classNames.match(matcher),
});
return !matcher.test(classNames);
}
}
return true;
}
export const createResolver = (
matchers: MatcherConstructor[] = [
getBoxMatcher,
getOffsetMatcher,
getRoundMatcher,
getBorderWidthMatcher,
getGroupsMatcher,
]
) => {
const canAddCandidate = matchCandidate.bind(null, matchers);
return (...classNames: Parameters<typeof cx>) => {
const key = cx(classNames).replace(spacingRegExp, " ").trim();
if (memo.has(key)) {
return memo.get(key);
}
const state = {};
const candidates = key.split(spacingRegExp).reverse();
let resultingClassName = candidates.shift() || "";
for (const candidate of candidates) {
const [, modifiers, className] = candidate.match(
modifiersAndRestRegExp
) || ["", ""];
if (
canAddCandidate({
classNames: resultingClassName,
candidate: className,
modifiers,
})
)
resultingClassName = `${candidate} ${resultingClassName}`;
}
memo.set(key, resultingClassName);
return resultingClassName;
};
};
export const tw = createResolver();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment