diff --git a/packages/ui/rollup.config.mjs b/packages/ui/rollup.config.mjs index 16f1f3217..84a0d7e98 100644 --- a/packages/ui/rollup.config.mjs +++ b/packages/ui/rollup.config.mjs @@ -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, ]; diff --git a/packages/ui/src/helpers/apply-prefix-v3.spec.ts b/packages/ui/src/helpers/apply-prefix-v3.spec.ts new file mode 100644 index 000000000..d4e9277b3 --- /dev/null +++ b/packages/ui/src/helpers/apply-prefix-v3.spec.ts @@ -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"); + }); +}); diff --git a/packages/ui/src/helpers/apply-prefix-v3.ts b/packages/ui/src/helpers/apply-prefix-v3.ts new file mode 100644 index 000000000..d28a810d7 --- /dev/null +++ b/packages/ui/src/helpers/apply-prefix-v3.ts @@ -0,0 +1,72 @@ +const cache = new Map(); + +/** + * 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; +} diff --git a/packages/ui/src/helpers/apply-prefix.spec.ts b/packages/ui/src/helpers/apply-prefix.spec.ts index 07895d64e..13f0f7ea2 100644 --- a/packages/ui/src/helpers/apply-prefix.spec.ts +++ b/packages/ui/src/helpers/apply-prefix.spec.ts @@ -3,7 +3,7 @@ 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", () => { @@ -11,50 +11,44 @@ describe("applyPrefix", () => { }); 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"); }); }); diff --git a/packages/ui/src/helpers/apply-prefix.ts b/packages/ui/src/helpers/apply-prefix.ts index 6c5ac8f57..7c6b2f92f 100644 --- a/packages/ui/src/helpers/apply-prefix.ts +++ b/packages/ui/src/helpers/apply-prefix.ts @@ -1,21 +1,19 @@ const cache = new Map(); /** - * 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); @@ -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(" "); diff --git a/packages/ui/src/helpers/convert-utilities-to-v4.ts b/packages/ui/src/helpers/convert-utilities-to-v4.ts index c9ea66b97..2b054bb8d 100644 --- a/packages/ui/src/helpers/convert-utilities-to-v4.ts +++ b/packages/ui/src/helpers/convert-utilities-to-v4.ts @@ -1,10 +1,10 @@ const cache = new Map(); /** - * 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) { @@ -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; diff --git a/packages/ui/src/helpers/resolve-theme.ts b/packages/ui/src/helpers/resolve-theme.ts index ea113f2df..aee33d0b9 100644 --- a/packages/ui/src/helpers/resolve-theme.ts +++ b/packages/ui/src/helpers/resolve-theme.ts @@ -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"; @@ -65,13 +66,12 @@ export function resolveTheme( 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(baseTheme, false); @@ -91,18 +91,21 @@ export function resolveTheme( } } - 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; }); } diff --git a/packages/ui/src/store/index.ts b/packages/ui/src/store/index.ts index 6c04838c5..0bf09860f 100644 --- a/packages/ui/src/store/index.ts +++ b/packages/ui/src/store/index.ts @@ -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) { diff --git a/packages/ui/src/tailwind/config.ts b/packages/ui/src/tailwind/config.ts index 636ad4f34..f8b2abbbd 100644 --- a/packages/ui/src/tailwind/config.ts +++ b/packages/ui/src/tailwind/config.ts @@ -4,7 +4,7 @@ import { resolveClassList, resolvePrefix, resolveVersion } from "./utils"; import type { Config } from "tailwindcss"; -export function getConfig(options: PluginOptions = {}): Partial { +export function getConfig(options: PluginOptions): Partial { return { safelist: getSafelist(options), theme: { @@ -13,6 +13,8 @@ export function getConfig(options: PluginOptions = {}): Partial { }; } -export function getSafelist(options: PluginOptions = {}): Config["safelist"] { - return resolveVersion(resolvePrefix(resolveClassList(options.components), options.prefix, options.separator)); +export function getSafelist(options: PluginOptions): Config["safelist"] { + return resolveVersion( + resolvePrefix(resolveClassList(options.components), options.prefix, options.separator, options.version), + ); } diff --git a/packages/ui/src/tailwind/index.ts b/packages/ui/src/tailwind/index.ts index 54b677d96..25cbf5be6 100644 --- a/packages/ui/src/tailwind/index.ts +++ b/packages/ui/src/tailwind/index.ts @@ -2,9 +2,9 @@ import plugin from "tailwindcss/plugin"; import { getConfig } from "./config"; import type { PluginOptions } from "./types"; -export default plugin.withOptions( +export default plugin.withOptions>( // plugin () => () => {}, // config - (options) => getConfig(options), + (options = {}) => getConfig({ ...options, version: 4 }), ); diff --git a/packages/ui/src/tailwind/types.ts b/packages/ui/src/tailwind/types.ts index 38e5e0ea7..1420dce43 100644 --- a/packages/ui/src/tailwind/types.ts +++ b/packages/ui/src/tailwind/types.ts @@ -22,7 +22,7 @@ export type PluginOptions = Partial<{ * * 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; }>; diff --git a/packages/ui/src/tailwind/utils.ts b/packages/ui/src/tailwind/utils.ts index 449e0c663..0720870b1 100644 --- a/packages/ui/src/tailwind/utils.ts +++ b/packages/ui/src/tailwind/utils.ts @@ -1,4 +1,5 @@ import { applyPrefix } from "../helpers/apply-prefix"; +import { applyPrefixV3 } from "../helpers/apply-prefix-v3"; import { convertUtilitiesToV4 } from "../helpers/convert-utilities-to-v4"; import { CLASS_LIST_MAP } from "./class-list"; import type { ClassList, ComponentName, PluginOptions } from "./types"; @@ -11,10 +12,21 @@ export function resolvePrefix( classList: ClassList, prefix?: PluginOptions["prefix"], separator?: PluginOptions["separator"], + version?: PluginOptions["version"], ): ClassList { prefix = prefix?.trim(); - return prefix ? classList.map((className) => applyPrefix(className, prefix, separator)) : classList; + if (!prefix) { + return classList; + } + + return classList.map((className) => { + if (version === 3) { + return applyPrefixV3(className, prefix, separator); + } + + return applyPrefix(className, prefix); + }); } export function resolveClassList(components?: ComponentName[]): ClassList { diff --git a/packages/ui/src/tailwind/v4.ts b/packages/ui/src/tailwind/v3.ts similarity index 55% rename from packages/ui/src/tailwind/v4.ts rename to packages/ui/src/tailwind/v3.ts index a38bb745c..2ca78f528 100644 --- a/packages/ui/src/tailwind/v4.ts +++ b/packages/ui/src/tailwind/v3.ts @@ -2,12 +2,9 @@ import plugin from "tailwindcss/plugin"; import { getConfig } from "./config"; import type { PluginOptions } from "./types"; -export default plugin.withOptions( +export default plugin.withOptions>( // plugin () => () => {}, // config - (options = {}) => { - options.version ??= 4; - return getConfig(options); - }, + (options = {}) => getConfig({ ...options, version: 3 }), ); diff --git a/packages/ui/src/theme/config-v3.tsx b/packages/ui/src/theme/config-v3.tsx new file mode 100644 index 000000000..f0d56ae12 --- /dev/null +++ b/packages/ui/src/theme/config-v3.tsx @@ -0,0 +1,10 @@ +import type { StoreProps } from "../store"; +import { StoreInit } from "../store/init"; + +export type ThemeConfigV3Props = Omit; + +export function ThemeConfigV3(props: ThemeConfigV3Props) { + return ; +} + +ThemeConfigV3.displayName = "ThemeConfigV3"; diff --git a/packages/ui/src/theme/config.tsx b/packages/ui/src/theme/config.tsx index d7132d247..d216b9c12 100644 --- a/packages/ui/src/theme/config.tsx +++ b/packages/ui/src/theme/config.tsx @@ -1,10 +1,10 @@ import type { StoreProps } from "../store"; import { StoreInit } from "../store/init"; -export type ThemeConfigProps = StoreProps; +export type ThemeConfigProps = Omit; -export function ThemeConfig({ mode, prefix, separator, version }: ThemeConfigProps) { - return ; +export function ThemeConfig(props: ThemeConfigProps) { + return ; } ThemeConfig.displayName = "ThemeConfig"; diff --git a/packages/ui/src/theme/index.ts b/packages/ui/src/theme/index.ts index 0be8bcc5f..46f87512b 100644 --- a/packages/ui/src/theme/index.ts +++ b/packages/ui/src/theme/index.ts @@ -94,5 +94,6 @@ export const theme: FlowbiteTheme = { }; export { ThemeConfig, type ThemeConfigProps } from "./config"; +export { ThemeConfigV3, type ThemeConfigV3Props } from "./config-v3"; export { ThemeModeScript, type ThemeModeScriptProps } from "./mode-script"; export { ThemeProvider, useThemeProvider, type ThemeProviderProps, type ThemeProviderValue } from "./provider";