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
16 changes: 16 additions & 0 deletions docs/src/content/docs/guides/color-scheme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<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
2 changes: 2 additions & 0 deletions docs/src/content/docs/reference/outlines.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ Utilities for controlling the outline style of an element.
<View className="outline-blue-500" /> // outlineColor: '#3B82F6'
<View className="outline-[#ff0000]" /> // outlineColor: '#ff0000'
<View className="outline-red-500/50" /> // outlineColor: '#EF4444' (50% opacity)
<View className="dark:outline-gray-200" /> // Theme-aware outline color
<View className="scheme:outline-brand" /> // Expands to light/dark variants
```

## Outline Style
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
144 changes: 144 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 @@ -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);
Expand Down Expand Up @@ -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");
Expand All @@ -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);
Expand Down
26 changes: 20 additions & 6 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 @@ -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-");
}

Expand Down Expand Up @@ -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.`,
);
}
Expand All @@ -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}`;
Expand All @@ -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}`,
},
];
}
Expand Down
Loading
Loading