Skip to content

Commit

Permalink
feat(tailwind): improve support for Tailwind CSS v4
Browse files Browse the repository at this point in the history
  • Loading branch information
SutuSebastian committed Jan 24, 2025
1 parent 0bf4df7 commit 68444e0
Show file tree
Hide file tree
Showing 16 changed files with 217 additions and 91 deletions.
2 changes: 1 addition & 1 deletion packages/ui/rollup.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const entries = [
"src/index.ts",
"src/icons/index.ts",
"src/tailwind/index.ts",
"src/tailwind/v4.ts",
"src/tailwind/v3.ts",
"src/theme/index.ts",
...componentEntries,
];
Expand Down
60 changes: 60 additions & 0 deletions packages/ui/src/helpers/apply-prefix-v3.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { applyPrefixV3 } from "./apply-prefix-v3";

describe("applyPrefix_v3", () => {
it("should return empty string when classNames is empty", () => {
expect(applyPrefixV3("", "tw-")).toBe("");
});

it("should return original classNames when prefix is empty", () => {
expect(applyPrefixV3("text-lg", "")).toBe("text-lg");
});

it("should add prefix to single class", () => {
expect(applyPrefixV3("text-lg", "tw-")).toBe("tw-text-lg");
});

it("should add prefix to multiple classes", () => {
expect(applyPrefixV3("text-lg font-bold", "tw-")).toBe("tw-text-lg tw-font-bold");
});

it("should handle classes with modifiers", () => {
expect(applyPrefixV3("dark:text-xl hover:text-lg", "tw-")).toBe("dark:tw-text-xl hover:tw-text-lg");
});

it("should preserve ! modifier", () => {
expect(applyPrefixV3("!text-lg", "tw-")).toBe("!tw-text-lg");
});

it("should preserve - modifier", () => {
expect(applyPrefixV3("-mt-2", "tw-")).toBe("-tw-mt-2");
});

it("should preserve ! and - modifiers", () => {
expect(applyPrefixV3("!-mt-2", "tw-")).toBe("!-tw-mt-2");
});

it("should handle arbitrary values", () => {
expect(applyPrefixV3("[&>*]:gap-4", "tw-")).toBe("[&>*]:tw-gap-4");
});

it("should handle custom separator", () => {
expect(applyPrefixV3("dark__text-xl", "tw-", "__")).toBe("dark__tw-text-xl");
});

it("should not add prefix if class already has it", () => {
expect(applyPrefixV3("tw-text-lg", "tw-")).toBe("tw-text-lg");
});

it("should handle extra whitespace", () => {
expect(applyPrefixV3(" text-lg font-bold ", "tw-")).toBe("tw-text-lg tw-font-bold");
});

it("should handle extra whitespace in prefix", () => {
expect(applyPrefixV3("text-lg", " tw- ")).toBe("tw-text-lg");
});

it("should handle multiple modifiers", () => {
expect(applyPrefixV3("dark:hover:!text-lg", "tw-")).toBe("dark:hover:!tw-text-lg");
});
});
72 changes: 72 additions & 0 deletions packages/ui/src/helpers/apply-prefix-v3.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
const cache = new Map<string, string>();

/**
* Applies a prefix to class names while preserving modifiers and arbitrary values.
*
* @param classNames - A string containing one or more CSS class names separated by spaces
* @param prefix - The prefix to be added to each class name
* @param separator - The separator used between class name parts (default ":")
* @returns A new string with the prefix applied to each class name
*/
export function applyPrefixV3(classNames: string, prefix: string, separator = ":"): string {
if (!classNames.trim().length || !prefix.trim().length) {
return classNames;
}

classNames = classNames.trim();
prefix = prefix.trim();
separator = separator.trim();

const cacheKey = classNames;
const cacheValue = cache.get(cacheKey);

if (cacheValue) {
return cacheValue;
}

const result = classNames
.split(/\s+/)
.map((className) => {
className = className.trim();

if (!className.length) {
return className;
}

if (className.startsWith("[") && className.endsWith("]")) {
return className;
}

const parts = className.split(separator);
const baseClass = parts.pop() ?? "";

let prefixedBaseClass = baseClass;

let modifiers = "";
if (prefixedBaseClass[0] === "!") {
modifiers = "!";
prefixedBaseClass = prefixedBaseClass.slice(1);
}
if (prefixedBaseClass[0] === "-") {
modifiers += "-";
prefixedBaseClass = prefixedBaseClass.slice(1);
}

if (prefixedBaseClass.startsWith(prefix)) {
return className;
}

prefixedBaseClass = modifiers + prefix + prefixedBaseClass;

if (!parts.length) {
return prefixedBaseClass;
}

return `${parts.join(separator)}${separator}${prefixedBaseClass}`;
})
.join(" ");

cache.set(cacheKey, result);

return result;
}
44 changes: 19 additions & 25 deletions packages/ui/src/helpers/apply-prefix.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,58 +3,52 @@ import { applyPrefix } from "./apply-prefix";

describe("applyPrefix", () => {
it("should return empty string when classNames is empty", () => {
expect(applyPrefix("", "tw-")).toBe("");
expect(applyPrefix("", "tw")).toBe("");
});

it("should return original classNames when prefix is empty", () => {
expect(applyPrefix("text-lg", "")).toBe("text-lg");
});

it("should add prefix to single class", () => {
expect(applyPrefix("text-lg", "tw-")).toBe("tw-text-lg");
expect(applyPrefix("text-lg", "tw")).toBe("tw:text-lg");
});

it("should add prefix to multiple classes", () => {
expect(applyPrefix("text-lg font-bold", "tw-")).toBe("tw-text-lg tw-font-bold");
expect(applyPrefix("text-lg font-bold", "tw")).toBe("tw:text-lg tw:font-bold");
});

it("should handle classes with modifiers", () => {
expect(applyPrefix("dark:text-xl hover:text-lg", "tw-")).toBe("dark:tw-text-xl hover:tw-text-lg");
});

it("should preserve ! modifier", () => {
expect(applyPrefix("!text-lg", "tw-")).toBe("!tw-text-lg");
it("should not add prefix if class already has it", () => {
expect(applyPrefix("tw:text-lg", "tw")).toBe("tw:text-lg");
});

it("should preserve - modifier", () => {
expect(applyPrefix("-mt-2", "tw-")).toBe("-tw-mt-2");
it("should handle extra whitespace", () => {
expect(applyPrefix(" text-lg font-bold ", "tw")).toBe("tw:text-lg tw:font-bold");
});

it("should preserve ! and - modifiers", () => {
expect(applyPrefix("!-mt-2", "tw-")).toBe("!-tw-mt-2");
it("should handle extra whitespace in prefix", () => {
expect(applyPrefix("text-lg", " tw ")).toBe("tw:text-lg");
});

it("should handle arbitrary values", () => {
expect(applyPrefix("[&>*]:gap-4", "tw-")).toBe("[&>*]:tw-gap-4");
expect(applyPrefix("[&>*]:space-y-6", "tw")).toBe("tw:[&>*]:space-y-6");
});

it("should handle custom separator", () => {
expect(applyPrefix("dark__text-xl", "tw-", "__")).toBe("dark__tw-text-xl");
it("should handle multiple arbitrary values", () => {
expect(applyPrefix("[&>*]:space-y-6 [mask-type:luminance]", "tw")).toBe(
"tw:[&>*]:space-y-6 tw:[mask-type:luminance]",
);
});

it("should not add prefix if class already has it", () => {
expect(applyPrefix("tw-text-lg", "tw-")).toBe("tw-text-lg");
it("should handle arbitrary variants with slashes", () => {
expect(applyPrefix("lg:p-4 sm/group-hover:p-8", "tw")).toBe("tw:lg:p-4 tw:sm/group-hover:p-8");
});

it("should handle extra whitespace", () => {
expect(applyPrefix(" text-lg font-bold ", "tw-")).toBe("tw-text-lg tw-font-bold");
});

it("should handle extra whitespace in prefix", () => {
expect(applyPrefix("text-lg", " tw- ")).toBe("tw-text-lg");
it("should handle data attributes", () => {
expect(applyPrefix("data-[size=lg]:p-8", "tw")).toBe("tw:data-[size=lg]:p-8");
});

it("should handle multiple modifiers", () => {
expect(applyPrefix("dark:hover:!text-lg", "tw-")).toBe("dark:hover:!tw-text-lg");
expect(applyPrefix("hover:dark:focus:text-white", "tw")).toBe("tw:hover:dark:focus:text-white");
});
});
39 changes: 4 additions & 35 deletions packages/ui/src/helpers/apply-prefix.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,19 @@
const cache = new Map<string, string>();

/**
* Applies a prefix to class names while preserving modifiers and arbitrary values.
* Applies a prefix to class names.
*
* @param classNames - A string containing one or more CSS class names separated by spaces
* @param prefix - The prefix to be added to each class name
* @param separator - The separator used between class name parts (default ":")
* @returns A new string with the prefix applied to each class name
*/
export function applyPrefix(classNames: string, prefix: string, separator = ":"): string {
export function applyPrefix(classNames: string, prefix: string): string {
if (!classNames.trim().length || !prefix.trim().length) {
return classNames;
}

classNames = classNames.trim();
prefix = prefix.trim();
separator = separator.trim();

const cacheKey = classNames;
const cacheValue = cache.get(cacheKey);
Expand All @@ -29,40 +27,11 @@ export function applyPrefix(classNames: string, prefix: string, separator = ":")
.map((className) => {
className = className.trim();

if (!className.length) {
if (!className.length || className.startsWith(prefix)) {
return className;
}

if (className.startsWith("[") && className.endsWith("]")) {
return className;
}

const parts = className.split(separator);
const baseClass = parts.pop() ?? "";

let prefixedBaseClass = baseClass;

let modifiers = "";
if (prefixedBaseClass[0] === "!") {
modifiers = "!";
prefixedBaseClass = prefixedBaseClass.slice(1);
}
if (prefixedBaseClass[0] === "-") {
modifiers += "-";
prefixedBaseClass = prefixedBaseClass.slice(1);
}

if (prefixedBaseClass.startsWith(prefix)) {
return className;
}

prefixedBaseClass = modifiers + prefix + prefixedBaseClass;

if (!parts.length) {
return prefixedBaseClass;
}

return `${parts.join(separator)}${separator}${prefixedBaseClass}`;
return `${prefix}:${className}`;
})
.join(" ");

Expand Down
9 changes: 5 additions & 4 deletions packages/ui/src/helpers/convert-utilities-to-v4.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
const cache = new Map<string, string>();

/**
* Convert TailwindCSS v3 utilities to v4
* Converts Tailwind CSS v3 utility classes to v4.
*
* @param {string} classNames
* @returns {string}
* @param {string} classNames - The string of class names to convert
* @returns {string} The converted class names string
*/
export function convertUtilitiesToV4(classNames: string): string {
if (!classNames.trim().length) {
Expand Down Expand Up @@ -52,6 +52,7 @@ const regexMap = [
[/\b(blur)\b(?!-)/g, "blur-sm"],
[/\b(rounded-sm)\b/g, "rounded-xs"],
[/\b(rounded)\b(?!-)/g, "rounded-sm"],
[/\b(outline-none)\b/g, "outline-hidden"],
// TODO: revisit this - it breaks anything focused using tab
// [/\b(outline-none)\b/g, "outline-hidden"],
[/\b(ring)\b(?!-)/g, "ring-3"],
] as const;
21 changes: 12 additions & 9 deletions packages/ui/src/helpers/resolve-theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useRef } from "react";
import { getPrefix, getSeparator, getVersion } from "../store";
import type { ApplyTheme, DeepPartialApplyTheme, DeepPartialBoolean } from "../types";
import { applyPrefix } from "./apply-prefix";
import { applyPrefixV3 } from "./apply-prefix-v3";
import { convertUtilitiesToV4 } from "./convert-utilities-to-v4";
import { deepMergeStrings } from "./deep-merge";
import { twMerge } from "./tailwind-merge";
Expand Down Expand Up @@ -65,13 +66,12 @@ export function resolveTheme<T>(
const prefix = getPrefix();
const separator = getSeparator();
const version = getVersion();
const v4 = version === 4;

const _custom = custom?.length ? custom?.filter((value) => value !== undefined) : undefined;
const _clearThemeList = clearThemeList?.length ? clearThemeList?.filter((value) => value !== undefined) : undefined;
const _applyThemeList = applyThemeList?.length ? applyThemeList?.filter((value) => value !== undefined) : undefined;

const baseTheme = _clearThemeList?.length || v4 || prefix ? klona(base) : base;
const baseTheme = _clearThemeList?.length || version === 4 || prefix ? klona(base) : base;

if (_clearThemeList?.length) {
const finalClearTheme = cloneWithValue<T, boolean>(baseTheme, false);
Expand All @@ -91,18 +91,21 @@ export function resolveTheme<T>(
}
}

if (v4 || prefix) {
if (version === 4 || prefix) {
stringIterator(baseTheme, (value) => {
let result = value;

if (v4) {
result = convertUtilitiesToV4(result);
if (version === 4) {
value = convertUtilitiesToV4(value);
}
if (prefix) {
result = applyPrefix(result, prefix, separator);
if (version === 3) {
value = applyPrefixV3(value, prefix, separator);
}
if (version === 4) {
value = applyPrefix(value, prefix);
}
}

return result;
return value;
});
}

Expand Down
9 changes: 7 additions & 2 deletions packages/ui/src/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,17 @@ export type StoreProps = DeepPartial<{
*
* If version is `4` the base class list will be converted to v4 utilities
* @see https://tailwindcss.com/docs/upgrade-guide#renamed-utilities
* @default 3
* @default 4
*/
version: 3 | 4;
}>;

const store: StoreProps = {};
const store: StoreProps = {
mode: undefined,
prefix: undefined,
separator: undefined,
version: 4,
};

export function setStore(data: StoreProps) {
if ("mode" in data) {
Expand Down
Loading

0 comments on commit 68444e0

Please # to comment.