diff --git a/docs/src/content/docs/guides/color-scheme.md b/docs/src/content/docs/guides/color-scheme.md index 5080cb6..9c72784 100644 --- a/docs/src/content/docs/guides/color-scheme.md +++ b/docs/src/content/docs/guides/color-scheme.md @@ -170,8 +170,24 @@ The `scheme:` modifier only works with color utilities: - ✅ `scheme:text-{color}` — Text colors - ✅ `scheme:bg-{color}` — Background colors - ✅ `scheme:border-{color}` — Border colors +- ✅ `scheme:outline-{color}` — Outline 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/docs/src/content/docs/reference/outlines.md b/docs/src/content/docs/reference/outlines.md index 7593e08..8367b36 100644 --- a/docs/src/content/docs/reference/outlines.md +++ b/docs/src/content/docs/reference/outlines.md @@ -24,6 +24,8 @@ Utilities for controlling the outline style of an element. // outlineColor: '#3B82F6' // outlineColor: '#ff0000' // outlineColor: '#EF4444' (50% opacity) + // Theme-aware outline color + // Expands to light/dark variants ``` ## Outline Style 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..d9493e3 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, @@ -418,6 +420,23 @@ describe("isColorClass", () => { expect(isColorClass("border-black")).toBe(true); }); + it("should return true for outline color classes", () => { + expect(isColorClass("outline-red-500")).toBe(true); + expect(isColorClass("outline-systemGray")).toBe(true); + expect(isColorClass("outline-black")).toBe(true); + }); + + it("should return false for non-color outline classes", () => { + expect(isColorClass("outline-none")).toBe(false); + expect(isColorClass("outline-solid")).toBe(false); + expect(isColorClass("outline-dashed")).toBe(false); + expect(isColorClass("outline-dotted")).toBe(false); + expect(isColorClass("outline-hidden")).toBe(false); + expect(isColorClass("outline-2")).toBe(false); + expect(isColorClass("outline-offset-2")).toBe(false); + expect(isColorClass("outline-[3px]")).toBe(false); + }); + it("should return false for non-color classes", () => { expect(isColorClass("m-4")).toBe(false); expect(isColorClass("p-2")).toBe(false); @@ -482,6 +501,118 @@ 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 expand outline color scheme modifier", () => { + const modifier = { modifier: "scheme" as const, baseClass: "outline-primary" }; + const result = expandSchemeModifier(modifier, customColors); + + expect(result).toHaveLength(2); + expect((result as [ParsedModifier, ParsedModifier])[0]).toEqual({ + modifier: "dark", + baseClass: "outline-primary-dark", + }); + expect((result as [ParsedModifier, ParsedModifier])[1]).toEqual({ + modifier: "light", + baseClass: "outline-primary-light", + }); + }); + + it("should produce parsable outline color variants", () => { + const [dark, light] = expandSchemeModifier( + { modifier: "scheme", baseClass: "outline-primary" }, + customColors, + ); + + expect(parseColor(dark.baseClass, customColors)).toEqual({ outlineColor: "#1E40AF" }); + expect(parseColor(light.baseClass, customColors)).toEqual({ outlineColor: "#BFDBFE" }); + }); + + it("should preserve integer and arbitrary opacity for scheme outline colors", () => { + expect( + expandSchemeModifier({ modifier: "scheme", baseClass: "outline-primary/25" }, customColors), + ).toEqual([ + { modifier: "dark", baseClass: "outline-primary-dark/25" }, + { modifier: "light", baseClass: "outline-primary-light/25" }, + ]); + + const [dark, light] = expandSchemeModifier( + { modifier: "scheme", baseClass: "outline-primary/[.37]" }, + customColors, + ); + expect(parseColor(dark.baseClass, customColors)).toEqual({ + outlineColor: applyOpacity(customColors["primary-dark"], 37), + }); + expect(parseColor(light.baseClass, customColors)).toEqual({ + outlineColor: applyOpacity(customColors["primary-light"], 37), + }); + }); + 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 +625,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..e36231b 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"; @@ -172,9 +174,18 @@ export function isDirectionalModifier(modifier: ModifierType): modifier is Direc * Check if a class name is a color-based utility class * * @param className - Class name to check - * @returns true if class is color-based (text-*, bg-*, border-*) + * @returns true if class is color-based (text-*, bg-*, border-*, outline-*) */ export function isColorClass(className: string): boolean { + if (className.startsWith("outline-")) { + const value = className.substring(8); + const isStyle = ["solid", "dashed", "dotted", "none", "hidden"].includes(value); + const isWidthOrOffset = /^\d/.test(value) || value.startsWith("offset-"); + const isNonColorArbitrary = value.startsWith("[") && !value.startsWith("[#"); + + return !isStyle && !isWidthOrOffset && !isNonColorArbitrary; + } + return className.startsWith("text-") || className.startsWith("bg-") || className.startsWith("border-"); } @@ -211,7 +222,7 @@ export function expandSchemeModifier( if (!isColorClass(baseClass)) { if (process.env.NODE_ENV !== "production") { console.warn( - `[react-native-tailwind] scheme: modifier only supports color classes (text-*, bg-*, border-*). ` + + `[react-native-tailwind] scheme: modifier only supports color classes (text-*, bg-*, border-*, outline-*). ` + `Found: "${baseClass}". This modifier will be ignored.`, ); } @@ -220,12 +231,15 @@ export function expandSchemeModifier( // Extract the color name from the class // e.g., "text-systemGray" -> "systemGray" - const match = baseClass.match(/^(text|bg|border)-(.+)$/); + const match = baseClass.match(/^(text|bg|border|outline)-(.+)$/); if (!match) { 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 +266,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; }