From 24fb82a713416d395ce4f25b776242b75db4a0b3 Mon Sep 17 00:00:00 2001 From: Evan Simpson <25159851+e-simpson@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:45:03 -0400 Subject: [PATCH] fix: preserve opacity in scheme modifiers --- docs/src/content/docs/guides/color-scheme.md | 15 ++++ src/parser/colors.test.ts | 17 ++++ src/parser/colors.ts | 18 ++--- src/parser/modifiers.test.ts | 82 ++++++++++++++++++++ src/parser/modifiers.ts | 11 ++- src/utils/colorUtils.test.ts | 60 +++++++++++++- src/utils/colorUtils.ts | 63 +++++++++++---- 7 files changed, 239 insertions(+), 27 deletions(-) diff --git a/docs/src/content/docs/guides/color-scheme.md b/docs/src/content/docs/guides/color-scheme.md index 5080cb6..9a74102 100644 --- a/docs/src/content/docs/guides/color-scheme.md +++ b/docs/src/content/docs/guides/color-scheme.md @@ -172,6 +172,21 @@ The `scheme:` modifier only works with color utilities: - ✅ `scheme:border-{color}` — Border colors - ❌ Other utilities — Ignored with development warning +Opacity modifiers are preserved when the semantic color expands: + +```tsx + +// dark:bg-primary-dark/25 light:bg-primary-light/25 + + +// dark:text-systemLabel-dark/80 light:text-systemLabel-light/80 + + +// dark:bg-primary-dark/[.37] light:bg-primary-light/[.37] +``` + +Named opacity modifiers use percentages from `/0` through `/100`. Arbitrary modifiers accept a raw alpha such as `/[.37]` or an explicit percentage such as `/[37%]`. If a configured color already includes an alpha channel, the modifier composes with that alpha instead of producing an invalid color. + ### Use Cases **Semantic color names:** diff --git a/src/parser/colors.test.ts b/src/parser/colors.test.ts index 15b8bd6..29c59c7 100644 --- a/src/parser/colors.test.ts +++ b/src/parser/colors.test.ts @@ -320,6 +320,21 @@ describe("parseColor - opacity modifiers", () => { expect(parseColor("border-[#abc]/60")).toEqual({ borderColor: "#AABBCC99" }); }); + it("should handle arbitrary raw and percentage opacity modifiers", () => { + expect(parseColor("bg-black/[.37]")).toEqual({ backgroundColor: "#0000005E" }); + expect(parseColor("text-white/[37%]")).toEqual({ color: "#FFFFFF5E" }); + expect(parseColor("border-red-500/[.5]")).toEqual({ + borderColor: applyOpacity(COLORS["red-500"], 50), + }); + }); + + it("should multiply opacity for colors with an existing alpha channel", () => { + expect(parseColor("bg-[#edecf7af]/25")).toEqual({ backgroundColor: "#EDECF72C" }); + expect(parseColor("text-translucent/[.5]", { translucent: "#11223380" })).toEqual({ + color: "#11223340", + }); + }); + it("should handle opacity modifier with custom colors", () => { const customColors = { "brand-primary": "#FF6B6B" }; expect(parseColor("bg-brand-primary/50", customColors)).toEqual({ backgroundColor: "#FF6B6B80" }); @@ -355,6 +370,8 @@ describe("parseColor - opacity modifiers", () => { expect(parseColor("bg-black/101")).toBeNull(); // > 100 expect(parseColor("bg-black/-1")).toBeNull(); // < 0 expect(parseColor("bg-black/150")).toBeNull(); // Way over 100 + expect(parseColor("bg-black/[1.1]")).toBeNull(); + expect(parseColor("bg-black/[101%]")).toBeNull(); }); it("should return null for malformed opacity syntax", () => { diff --git a/src/parser/colors.ts b/src/parser/colors.ts index 5631e17..7431fbc 100644 --- a/src/parser/colors.ts +++ b/src/parser/colors.ts @@ -3,7 +3,7 @@ */ import type { StyleObject } from "../types"; -import { COLORS, applyOpacity, parseArbitraryColor } from "../utils/colorUtils"; +import { COLORS, applyOpacity, parseArbitraryColor, parseColorOpacityModifier } from "../utils/colorUtils"; // Re-export COLORS for backward compatibility and tests export { COLORS }; @@ -21,18 +21,16 @@ export function parseColor(cls: string, customColors?: Record): // Helper to parse color with optional opacity modifier // Uses internal implementation to preserve warnings for invalid arbitrary colors const parseColorWithOpacity = (colorKey: string): string | null => { - // Check for opacity modifier: blue-500/50 - const opacityMatch = colorKey.match(/^(.+)\/(\d+)$/); - if (opacityMatch) { - const baseColorKey = opacityMatch[1]; - const opacity = Number.parseInt(opacityMatch[2], 10); - - // Validate opacity range (0-100) - if (opacity < 0 || opacity > 100) { + // Check for opacity modifier: blue-500/50, blue-500/[.5], blue-500/[50%] + const opacityModifier = parseColorOpacityModifier(colorKey); + if (opacityModifier) { + const { baseColorKey, opacity } = opacityModifier; + if (opacity === null) { /* v8 ignore next 5 */ if (process.env.NODE_ENV !== "production") { console.warn( - `[react-native-tailwind] Invalid opacity value: ${opacity}. Opacity must be between 0 and 100.`, + `[react-native-tailwind] Invalid opacity modifier: ${opacityModifier.suffix}. ` + + "Use an integer percentage from 0 to 100, a raw arbitrary alpha from 0 to 1, or an arbitrary percentage.", ); } return null; diff --git a/src/parser/modifiers.test.ts b/src/parser/modifiers.test.ts index ca4fe63..afc0aab 100644 --- a/src/parser/modifiers.test.ts +++ b/src/parser/modifiers.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; +import { applyOpacity } from "../utils/colorUtils"; +import { parseColor } from "./colors"; import type { ParsedModifier } from "./modifiers"; import { expandSchemeModifier, @@ -482,6 +484,73 @@ describe("expandSchemeModifier", () => { }); }); + it("should preserve opacity modifiers when expanding scheme colors", () => { + const modifier = { modifier: "scheme" as const, baseClass: "bg-primary/25" }; + const result = expandSchemeModifier(modifier, customColors); + + expect(result).toEqual([ + { modifier: "dark", baseClass: "bg-primary-dark/25" }, + { modifier: "light", baseClass: "bg-primary-light/25" }, + ]); + }); + + it("should produce parsable color variants with the requested opacity", () => { + const [dark, light] = expandSchemeModifier( + { modifier: "scheme", baseClass: "bg-primary/25" }, + customColors, + ); + + expect(parseColor(dark.baseClass, customColors)).toEqual({ + backgroundColor: applyOpacity(customColors["primary-dark"], 25), + }); + expect(parseColor(light.baseClass, customColors)).toEqual({ + backgroundColor: applyOpacity(customColors["primary-light"], 25), + }); + }); + + it("should preserve arbitrary opacity modifiers when expanding scheme colors", () => { + const result = expandSchemeModifier({ modifier: "scheme", baseClass: "bg-primary/[.37]" }, customColors); + + expect(result).toEqual([ + { modifier: "dark", baseClass: "bg-primary-dark/[.37]" }, + { modifier: "light", baseClass: "bg-primary-light/[.37]" }, + ]); + expect(parseColor(result[0].baseClass, customColors)).toEqual({ + backgroundColor: applyOpacity(customColors["primary-dark"], 37), + }); + }); + + it("should compose scheme opacity with configured colors that already have alpha", () => { + const colors = { + "glass-dark": "#11223380", + "glass-light": "#EDECF7AF", + }; + const [dark, light] = expandSchemeModifier({ modifier: "scheme", baseClass: "bg-glass/50" }, colors); + + expect(parseColor(dark.baseClass, colors)).toEqual({ backgroundColor: "#11223340" }); + expect(parseColor(light.baseClass, colors)).toEqual({ backgroundColor: "#EDECF758" }); + }); + + it("should preserve edge opacity values for every supported color prefix", () => { + expect(expandSchemeModifier({ modifier: "scheme", baseClass: "text-systemGray/0" }, customColors)).toEqual( + [ + { modifier: "dark", baseClass: "text-systemGray-dark/0" }, + { modifier: "light", baseClass: "text-systemGray-light/0" }, + ], + ); + expect(expandSchemeModifier({ modifier: "scheme", baseClass: "border-accent/100" }, customColors)).toEqual( + [ + { modifier: "dark", baseClass: "border-accent-dark/100" }, + { modifier: "light", baseClass: "border-accent-light/100" }, + ], + ); + }); + + it("should validate scheme variants without including the opacity suffix", () => { + const modifier = { modifier: "scheme" as const, baseClass: "bg-missing/25" }; + expect(expandSchemeModifier(modifier, customColors)).toEqual([]); + }); + it("should use custom suffixes when provided", () => { const modifier = { modifier: "scheme" as const, baseClass: "text-systemGray" }; const _result = expandSchemeModifier(modifier, customColors, "-darkMode", "-lightMode"); @@ -494,6 +563,19 @@ describe("expandSchemeModifier", () => { expect(expandSchemeModifier(modifier, expectedColors, "-darkMode", "-lightMode")).toHaveLength(2); }); + it("should combine opacity with custom scheme suffixes", () => { + const modifier = { modifier: "scheme" as const, baseClass: "text-systemGray/50" }; + const colors = { + "systemGray-night": "#333333", + "systemGray-day": "#CCCCCC", + }; + + expect(expandSchemeModifier(modifier, colors, "-night", "-day")).toEqual([ + { modifier: "dark", baseClass: "text-systemGray-night/50" }, + { modifier: "light", baseClass: "text-systemGray-day/50" }, + ]); + }); + it("should return empty array for non-color classes", () => { const modifier = { modifier: "scheme" as const, baseClass: "m-4" }; const result = expandSchemeModifier(modifier, customColors); diff --git a/src/parser/modifiers.ts b/src/parser/modifiers.ts index d2d7eab..7111e1e 100644 --- a/src/parser/modifiers.ts +++ b/src/parser/modifiers.ts @@ -6,6 +6,8 @@ * - Directional modifiers: rtl:, ltr: (RTL-aware styling) */ +import { parseColorOpacityModifier } from "../utils/colorUtils"; + export type StateModifierType = "active" | "hover" | "focus" | "disabled" | "placeholder"; export type PlatformModifierType = "ios" | "android" | "web"; export type ColorSchemeModifierType = "dark" | "light"; @@ -225,7 +227,10 @@ export function expandSchemeModifier( return []; } - const [, prefix, colorName] = match; + const [, prefix, colorWithOpacity] = match; + const opacityModifier = parseColorOpacityModifier(colorWithOpacity); + const colorName = opacityModifier?.baseColorKey ?? colorWithOpacity; + const opacitySuffix = opacityModifier?.suffix ?? ""; // Build variant class names const darkColorName = `${colorName}${darkSuffix}`; @@ -252,11 +257,11 @@ export function expandSchemeModifier( return [ { modifier: "dark" as ColorSchemeModifierType, - baseClass: `${prefix}-${darkColorName}`, + baseClass: `${prefix}-${darkColorName}${opacitySuffix}`, }, { modifier: "light" as ColorSchemeModifierType, - baseClass: `${prefix}-${lightColorName}`, + baseClass: `${prefix}-${lightColorName}${opacitySuffix}`, }, ]; } diff --git a/src/utils/colorUtils.test.ts b/src/utils/colorUtils.test.ts index 4e1e08a..08f22e7 100644 --- a/src/utils/colorUtils.test.ts +++ b/src/utils/colorUtils.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vitest"; -import { COLORS, applyOpacity, parseArbitraryColor, parseColorValue } from "./colorUtils"; +import { + COLORS, + applyOpacity, + parseArbitraryColor, + parseColorOpacityModifier, + parseColorValue, +} from "./colorUtils"; describe("COLORS", () => { it("should include basic colors", () => { @@ -31,6 +37,12 @@ describe("applyOpacity", () => { expect(applyOpacity("#00f", 75)).toBe("#0000FFBF"); }); + it("should multiply existing alpha on 8-digit hex colors", () => { + expect(applyOpacity("#ff000080", 50)).toBe("#FF000040"); + expect(applyOpacity("#edecf7af", 25)).toBe("#EDECF72C"); + expect(applyOpacity("#edecf7af", 100)).toBe("#EDECF7AF"); + }); + it("should handle various opacity values", () => { expect(applyOpacity("#000000", 0)).toBe("#00000000"); expect(applyOpacity("#000000", 25)).toBe("#00000040"); @@ -52,6 +64,39 @@ describe("applyOpacity", () => { }); }); +describe("parseColorOpacityModifier", () => { + it("should parse named percentages and arbitrary alpha values", () => { + expect(parseColorOpacityModifier("card/35")).toEqual({ + baseColorKey: "card", + opacity: 35, + suffix: "/35", + }); + expect(parseColorOpacityModifier("card/[.37]")).toEqual({ + baseColorKey: "card", + opacity: 37, + suffix: "/[.37]", + }); + expect(parseColorOpacityModifier("card/[37%]")).toEqual({ + baseColorKey: "card", + opacity: 37, + suffix: "/[37%]", + }); + }); + + it("should preserve recognized but out-of-range modifiers for validation", () => { + expect(parseColorOpacityModifier("card/101")).toEqual({ + baseColorKey: "card", + opacity: null, + suffix: "/101", + }); + expect(parseColorOpacityModifier("card/[1.1]")).toEqual({ + baseColorKey: "card", + opacity: null, + suffix: "/[1.1]", + }); + }); +}); + describe("parseArbitraryColor", () => { it("should parse 6-digit hex colors", () => { expect(parseArbitraryColor("[#ff0000]")).toBe("#ff0000"); @@ -144,6 +189,17 @@ describe("parseColorValue", () => { expect(parseColorValue("[#0000ff]/80")).toBe("#0000FFCC"); }); + it("should apply arbitrary raw and percentage opacity values", () => { + expect(parseColorValue("red-500/[.37]")).toBe(applyOpacity(COLORS["red-500"], 37)); + expect(parseColorValue("black/[37%]")).toBe("#0000005E"); + expect(parseColorValue("[#ff0000]/[.5]")).toBe("#FF000080"); + }); + + it("should compose opacity with colors that already have alpha", () => { + expect(parseColorValue("[#ff000080]/50")).toBe("#FF000040"); + expect(parseColorValue("translucent/[.25]", { translucent: "#edecf7af" })).toBe("#EDECF72C"); + }); + it("should handle edge opacity values", () => { expect(parseColorValue("red-500/0")).toBe(applyOpacity(COLORS["red-500"], 0)); expect(parseColorValue("red-500/100")).toBe(applyOpacity(COLORS["red-500"], 100)); @@ -153,6 +209,8 @@ describe("parseColorValue", () => { expect(parseColorValue("red-500/101")).toBeNull(); expect(parseColorValue("red-500/-1")).toBeNull(); expect(parseColorValue("red-500/abc")).toBeNull(); + expect(parseColorValue("red-500/[1.1]")).toBeNull(); + expect(parseColorValue("red-500/[101%]")).toBeNull(); }); it("should keep transparent unchanged with opacity", () => { diff --git a/src/utils/colorUtils.ts b/src/utils/colorUtils.ts index 27ecbca..9740cc7 100644 --- a/src/utils/colorUtils.ts +++ b/src/utils/colorUtils.ts @@ -16,8 +16,9 @@ export const COLORS: Record = { }; /** - * Apply opacity to hex color by appending alpha channel - * @param hex - Hex color string (e.g., "#ff0000", "#f00", or "transparent") + * Apply opacity to a hex color. Existing alpha is multiplied, matching + * Tailwind's color-mix-with-transparent semantics. + * @param hex - Hex color string (e.g., "#ff0000", "#f00", "#ff000080", or "transparent") * @param opacity - Opacity value 0-100 (e.g., 50 for 50%) * @returns 8-digit hex with alpha (e.g., "#FF000080") or transparent */ @@ -27,7 +28,7 @@ export function applyOpacity(hex: string, opacity: number): string { } const cleanHex = hex.replace(/^#/, ""); - const fullHex = + const normalizedHex = cleanHex.length === 3 ? cleanHex .split("") @@ -35,10 +36,49 @@ export function applyOpacity(hex: string, opacity: number): string { .join("") : cleanHex; - const alpha = Math.round((opacity / 100) * 255); + const rgbHex = normalizedHex.slice(0, 6); + const existingAlpha = normalizedHex.length === 8 ? Number.parseInt(normalizedHex.slice(6), 16) : 255; + const alpha = Math.round((opacity / 100) * existingAlpha); const alphaHex = alpha.toString(16).padStart(2, "0").toUpperCase(); - return `#${fullHex.toUpperCase()}${alphaHex}`; + return `#${rgbHex.toUpperCase()}${alphaHex}`; +} + +export type ColorOpacityModifier = { + baseColorKey: string; + opacity: number | null; + suffix: string; +}; + +const COLOR_OPACITY_MODIFIER_PATTERN = /^(.+)\/(\d+|\[((?:\d+(?:\.\d*)?|\.\d+)(?:%)?)\])$/; + +/** + * Split and parse a Tailwind color opacity modifier. + * Named values are percentages (`/50`), while arbitrary values accept a raw + * alpha (`/[.5]`) or an explicit percentage (`/[50%]`). + */ +export function parseColorOpacityModifier(colorKey: string): ColorOpacityModifier | null { + const match = colorKey.match(COLOR_OPACITY_MODIFIER_PATTERN); + if (!match) { + return null; + } + + const [, baseColorKey, modifier, arbitraryValue] = match; + let opacity: number; + + if (arbitraryValue === undefined) { + opacity = Number.parseInt(modifier, 10); + } else if (arbitraryValue.endsWith("%")) { + opacity = Number.parseFloat(arbitraryValue.slice(0, -1)); + } else { + opacity = Number.parseFloat(arbitraryValue) * 100; + } + + return { + baseColorKey, + opacity: Number.isFinite(opacity) && opacity >= 0 && opacity <= 100 ? opacity : null, + suffix: `/${modifier}`, + }; } /** @@ -77,14 +117,11 @@ export function parseColorValue(colorKey: string, customColors?: Record 100) { + // Check for opacity modifier: red-500/50, red-500/[.5], [#ff0000]/[50%] + const opacityModifier = parseColorOpacityModifier(colorKey); + if (opacityModifier) { + const { baseColorKey, opacity } = opacityModifier; + if (opacity === null) { return null; }