Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/src/content/docs/guides/color-scheme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<View className="scheme:bg-primary/25" />
// dark:bg-primary-dark/25 light:bg-primary-light/25

<Text className="scheme:text-systemLabel/80" />
// dark:text-systemLabel-dark/80 light:text-systemLabel-light/80

<View className="scheme:bg-primary/[.37]" />
// 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:**
Expand Down
17 changes: 17 additions & 0 deletions src/parser/colors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down Expand Up @@ -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", () => {
Expand Down
18 changes: 8 additions & 10 deletions src/parser/colors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -21,18 +21,16 @@ export function parseColor(cls: string, customColors?: Record<string, string>):
// 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;
Expand Down
82 changes: 82 additions & 0 deletions src/parser/modifiers.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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");
Expand All @@ -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);
Expand Down
11 changes: 8 additions & 3 deletions src/parser/modifiers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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}`;
Expand All @@ -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}`,
},
];
}
Expand Down
60 changes: 59 additions & 1 deletion src/utils/colorUtils.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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");
Expand All @@ -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");
Expand Down Expand Up @@ -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));
Expand All @@ -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", () => {
Expand Down
Loading
Loading