From d8782ed189413e3f850925c7cb8a3b4e7f7b9039 Mon Sep 17 00:00:00 2001 From: Evan Simpson <25159851+e-simpson@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:30:25 -0400 Subject: [PATCH 1/4] feat: add brightness filter utilities --- docs/astro.config.mjs | 1 + docs/src/content/docs/reference/filters.md | 36 +++++++++++++++ src/babel/plugin/visitors/className.test.ts | 15 ++++++ src/index.ts | 1 + src/parser/filters.test.ts | 47 +++++++++++++++++++ src/parser/filters.ts | 51 +++++++++++++++++++++ src/parser/index.ts | 3 ++ src/types/core.ts | 4 +- src/utils/mergeStyles.ts | 7 +-- 9 files changed, 161 insertions(+), 4 deletions(-) create mode 100644 docs/src/content/docs/reference/filters.md create mode 100644 src/parser/filters.test.ts create mode 100644 src/parser/filters.ts diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index d6e7a0b..7484856 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -57,6 +57,7 @@ export default defineConfig({ { label: "Borders", slug: "reference/borders" }, { label: "Outlines", slug: "reference/outlines" }, { label: "Shadows & Elevation", slug: "reference/shadows" }, + { label: "Filters", slug: "reference/filters" }, { label: "Aspect Ratio", slug: "reference/aspect-ratio" }, { label: "Transforms", slug: "reference/transforms" }, { label: "Sizing", slug: "reference/sizing" }, diff --git a/docs/src/content/docs/reference/filters.md b/docs/src/content/docs/reference/filters.md new file mode 100644 index 0000000..0c90138 --- /dev/null +++ b/docs/src/content/docs/reference/filters.md @@ -0,0 +1,36 @@ +--- +title: Filters +description: Apply native visual filters to views +--- + +Apply brightness filters with React Native's `filter` style property. + +> **Note**: Filters require a React Native version and renderer that support the `filter` style property. Brightness is available on both iOS and Android when native filter support is enabled. + +## Brightness + +Numeric utilities use percentages, matching Tailwind CSS: + +```tsx + // filter: [{ brightness: 0.5 }] + // filter: [{ brightness: 1 }] + // filter: [{ brightness: 1.25 }] + // filter: [{ brightness: 2 }] +``` + +Numeric brightness classes are dynamic, so values outside the examples also work: + +```tsx + // filter: [{ brightness: 1.01 }] +``` + +## Arbitrary Values + +Use bracket syntax for raw React Native amounts or percentages: + +```tsx + // filter: [{ brightness: 1.01 }] + // filter: [{ brightness: 0.8 }] +``` + +Brightness values must be non-negative. Unsupported units and negative values are ignored with a development warning. diff --git a/src/babel/plugin/visitors/className.test.ts b/src/babel/plugin/visitors/className.test.ts index 0f9a484..4e9c67c 100644 --- a/src/babel/plugin/visitors/className.test.ts +++ b/src/babel/plugin/visitors/className.test.ts @@ -23,6 +23,21 @@ describe("className visitor - basic transformation", () => { expect(output).toContain("style:"); }); + it("should transform brightness filters", () => { + const input = ` + import { View } from 'react-native'; + export function Component() { + return ; + } + `; + + const output = transform(input, undefined, true); + + expect(output).not.toContain("className"); + expect(output).toContain("_brightness_1_01"); + expect(output).toMatch(/filter:\s*\[\s*{\s*brightness:\s*1\.01/); + }); + it("should work with both tw and className in same file", () => { const input = ` import { tw } from '@mgcrea/react-native-tailwind'; diff --git a/src/index.ts b/src/index.ts index 0bd46f6..62f16cb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,7 @@ export { parseAspectRatio, parseBorder, parseColor, + parseFilter, parseLayout, parseOutline, parsePlaceholderClass, diff --git a/src/parser/filters.test.ts b/src/parser/filters.test.ts new file mode 100644 index 0000000..5f25994 --- /dev/null +++ b/src/parser/filters.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import { parseFilter } from "./filters"; +import { parseClassName } from "./index"; + +describe("parseFilter - brightness", () => { + it("should parse numeric brightness values as percentages", () => { + expect(parseFilter("brightness-0")).toEqual({ filter: [{ brightness: 0 }] }); + expect(parseFilter("brightness-50")).toEqual({ filter: [{ brightness: 0.5 }] }); + expect(parseFilter("brightness-100")).toEqual({ filter: [{ brightness: 1 }] }); + expect(parseFilter("brightness-101")).toEqual({ filter: [{ brightness: 1.01 }] }); + expect(parseFilter("brightness-125")).toEqual({ filter: [{ brightness: 1.25 }] }); + expect(parseFilter("brightness-200")).toEqual({ filter: [{ brightness: 2 }] }); + }); + + it("should parse arbitrary numeric brightness values", () => { + expect(parseFilter("brightness-[1.01]")).toEqual({ filter: [{ brightness: 1.01 }] }); + expect(parseFilter("brightness-[.5]")).toEqual({ filter: [{ brightness: 0.5 }] }); + expect(parseFilter("brightness-[2]")).toEqual({ filter: [{ brightness: 2 }] }); + }); + + it("should parse arbitrary percentage brightness values", () => { + expect(parseFilter("brightness-[80%]")).toEqual({ filter: [{ brightness: 0.8 }] }); + expect(parseFilter("brightness-[101%]")).toEqual({ filter: [{ brightness: 1.01 }] }); + }); + + it("should reject unsupported or negative brightness values", () => { + expect(parseFilter("brightness-[-1]")).toBeNull(); + expect(parseFilter("brightness-[1.01px]")).toBeNull(); + expect(parseFilter("brightness-[abc]")).toBeNull(); + expect(parseFilter("brightness-auto")).toBeNull(); + expect(parseFilter("brightness-")).toBeNull(); + }); + + it("should return null for unrelated classes", () => { + expect(parseFilter("contrast-100")).toBeNull(); + expect(parseFilter("bg-white")).toBeNull(); + expect(parseFilter("")).toBeNull(); + }); + + it("should integrate with the class parser", () => { + expect(parseClassName("brightness-[1.01] opacity-80")).toEqual({ + filter: [{ brightness: 1.01 }], + opacity: 0.8, + }); + }); +}); diff --git a/src/parser/filters.ts b/src/parser/filters.ts new file mode 100644 index 0000000..1f2fdc7 --- /dev/null +++ b/src/parser/filters.ts @@ -0,0 +1,51 @@ +/** + * Filter utilities supported by React Native's filter style property. + */ + +import type { StyleObject } from "../types"; + +const NUMBER_PATTERN = String.raw`(?:\d+(?:\.\d*)?|\.\d+)`; + +/** + * Parse a Tailwind brightness amount. + * Named numeric utilities are percentages (brightness-101 -> 1.01), while + * bracketed numbers are raw React Native amounts (brightness-[1.01] -> 1.01). + */ +function parseBrightnessAmount(value: string): number | null { + const arbitraryMatch = value.match(new RegExp(`^\\[(${NUMBER_PATTERN})(%)?\\]$`)); + if (arbitraryMatch) { + const amount = Number.parseFloat(arbitraryMatch[1]); + return arbitraryMatch[2] === "%" ? amount / 100 : amount; + } + + if (value.startsWith("[") && value.endsWith("]")) { + /* v8 ignore next 5 */ + if (process.env.NODE_ENV !== "production") { + console.warn( + `[react-native-tailwind] Invalid arbitrary brightness value: ${value}. Only non-negative numbers and percentages are supported (e.g., [1.01], [80%]).`, + ); + } + return null; + } + + if (new RegExp(`^${NUMBER_PATTERN}$`).test(value)) { + return Number.parseFloat(value) / 100; + } + + return null; +} + +/** + * Parse filter classes. + * @param cls - The class name to parse + */ +export function parseFilter(cls: string): StyleObject | null { + if (cls.startsWith("brightness-")) { + const amount = parseBrightnessAmount(cls.substring(11)); + if (amount !== null) { + return { filter: [{ brightness: amount }] }; + } + } + + return null; +} diff --git a/src/parser/index.ts b/src/parser/index.ts index 0e89fcc..1fbf197 100644 --- a/src/parser/index.ts +++ b/src/parser/index.ts @@ -8,6 +8,7 @@ import { mergeStyles } from "../utils/mergeStyles"; import { parseAspectRatio } from "./aspectRatio"; import { parseBorder } from "./borders"; import { parseColor } from "./colors"; +import { parseFilter } from "./filters"; import { parseLayout } from "./layout"; import { parseOutline } from "./outline"; import { parseShadow } from "./shadows"; @@ -63,6 +64,7 @@ export function parseClass(cls: string, customTheme?: CustomTheme): StyleObject (cls: string) => parseTypography(cls, customTheme?.fontFamily, customTheme?.fontSize), (cls: string) => parseSizing(cls, customTheme?.spacing), (cls: string) => parseShadow(cls, customTheme?.colors), + parseFilter, parseAspectRatio, (cls: string) => parseTransform(cls, customTheme?.spacing), ]; @@ -87,6 +89,7 @@ export function parseClass(cls: string, customTheme?: CustomTheme): StyleObject export { parseAspectRatio } from "./aspectRatio"; export { parseBorder } from "./borders"; export { parseColor } from "./colors"; +export { parseFilter } from "./filters"; export { parseLayout } from "./layout"; export { parseOutline } from "./outline"; export { parsePlaceholderClass, parsePlaceholderClasses } from "./placeholder"; diff --git a/src/types/core.ts b/src/types/core.ts index 1ea60c5..06ad056 100644 --- a/src/types/core.ts +++ b/src/types/core.ts @@ -18,9 +18,11 @@ export type TransformStyle = | { perspective?: number }; export type ShadowOffsetStyle = { width: number; height: number }; +export type FilterStyle = { brightness: number }; export type StyleObject = { - [key: string]: string | number | ShadowOffsetStyle | TransformStyle[] | undefined; + [key: string]: string | number | ShadowOffsetStyle | TransformStyle[] | FilterStyle[] | undefined; + filter?: FilterStyle[]; shadowOffset?: ShadowOffsetStyle; transform?: TransformStyle[]; }; diff --git a/src/utils/mergeStyles.ts b/src/utils/mergeStyles.ts index 043a6a3..79144d3 100644 --- a/src/utils/mergeStyles.ts +++ b/src/utils/mergeStyles.ts @@ -84,13 +84,14 @@ export function mergeStyles(target: StyleObject, source: StyleObject): StyleObje // Handle transform arrays specially if (key === "transform" && Array.isArray(sourceValue)) { - const targetValue = target[key]; + const sourceTransforms = sourceValue as TransformStyle[]; + const targetValue = target.transform; if (Array.isArray(targetValue)) { // Merge transforms with "last wins" for same types - target.transform = mergeTransforms(targetValue, sourceValue); + target.transform = mergeTransforms(targetValue, sourceTransforms); } else { // No existing array, just assign - target[key] = sourceValue; + target.transform = sourceTransforms; } } else { // Standard Object.assign behavior for non-transform properties From 2903d36e429fb62a1418ebe812a85ce793797eee Mon Sep 17 00:00:00 2001 From: Evan Simpson <25159851+e-simpson@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:39:39 -0400 Subject: [PATCH 2/4] feat: expand native filter support --- docs/src/content/docs/reference/filters.md | 122 +++++++++++-- src/babel/plugin/visitors/className.test.ts | 17 ++ src/index.ts | 3 +- src/parser/filters.test.ts | 138 ++++++++++++--- src/parser/filters.ts | 180 +++++++++++++++++++- src/parser/index.ts | 2 +- src/types/core.ts | 17 +- src/utils/mergeStyles.test.ts | 23 +++ src/utils/mergeStyles.ts | 45 ++++- 9 files changed, 492 insertions(+), 55 deletions(-) diff --git a/docs/src/content/docs/reference/filters.md b/docs/src/content/docs/reference/filters.md index 0c90138..3ab15e8 100644 --- a/docs/src/content/docs/reference/filters.md +++ b/docs/src/content/docs/reference/filters.md @@ -3,34 +3,126 @@ title: Filters description: Apply native visual filters to views --- -Apply brightness filters with React Native's `filter` style property. +Apply composable visual filters with React Native's native `filter` style property. -> **Note**: Filters require a React Native version and renderer that support the `filter` style property. Brightness is available on both iOS and Android when native filter support is enabled. +> **Note**: Filters require a React Native version and renderer that support the `filter` style property. Applying a filter also implies `overflow: hidden`, so descendants are clipped to the view's bounds. -## Brightness +## Platform Support -Numeric utilities use percentages, matching Tailwind CSS: +| Utility | iOS | Android | +|---------|-----|---------| +| `brightness-*` | ✅ | ✅ | +| `blur-*` | ❌ | ✅ | +| `contrast-*` | ❌ | ✅ | +| `drop-shadow-*` | ❌ | ✅ | +| `grayscale-*` | ❌ | ✅ | +| `hue-rotate-*` | ❌ | ✅ | +| `invert-*` | ❌ | ✅ | +| `saturate-*` | ❌ | ✅ | +| `sepia-*` | ❌ | ✅ | + +React Native also supports filter-level opacity on iOS and Android, but Tailwind's `opacity-*` class already maps to the native `opacity` style property in this package. It remains a regular opacity utility to avoid an ambiguous class collision. + +## Percentage Filters + +Brightness, contrast, grayscale, invert, saturate, and sepia use percentage-based numeric utilities: + +```tsx + // { brightness: 1.01 } + // { contrast: 1.25 } + // { grayscale: 0.5 } + // { invert: 0.25 } + // { saturate: 1.5 } + // { sepia: 0.75 } +``` + +The full-effect forms are also supported: + +```tsx + +``` + +Use bracket syntax for raw React Native amounts or explicit percentages: + +```tsx + // { brightness: 1.01 } + // { contrast: 0.8 } +``` + +## Blur + +Blur uses Tailwind's pixel scale: + +```tsx + // { blur: 4 } + // { blur: 8 } + // { blur: 12 } + // { blur: 16 } + // { blur: 24 } + // { blur: 40 } + // { blur: 64 } + +``` + +Arbitrary blur values accept non-negative pixels: ```tsx - // filter: [{ brightness: 0.5 }] - // filter: [{ brightness: 1 }] - // filter: [{ brightness: 1.25 }] - // filter: [{ brightness: 2 }] + + ``` -Numeric brightness classes are dynamic, so values outside the examples also work: +## Hue Rotation + +Numeric hue rotation utilities use degrees and support negative values: + +```tsx + // { hueRotate: '45deg' } + // { hueRotate: '-90deg' } +``` + +Arbitrary values support `deg` and `rad` angles: + +```tsx + + +``` + +## Drop Shadow + +Drop shadows use Tailwind's `xs` through `2xl` presets and operate on the rendered alpha mask: ```tsx - // filter: [{ brightness: 1.01 }] + + + + ``` -## Arbitrary Values +Arbitrary drop shadows accept X offset, Y offset, optional blur, and a preset, custom, or hex color: + +```tsx + + +``` + +Standalone `drop-shadow-{color}` utilities are not emitted because React Native requires a complete `dropShadow` object rather than Tailwind's separate CSS color variable. Put the color in an arbitrary drop shadow instead. + +## Combining Filters + +Different filter utilities compile into one ordered native filter array. If the same filter type appears more than once, the last value wins: + +```tsx + +// filter: [{ blur: 8 }, { brightness: 1.1 }, { contrast: 1.25 }] + + +// filter: [{ brightness: 0.9 }] +``` -Use bracket syntax for raw React Native amounts or percentages: +Use `filter-none` to clear filters that appear before it: ```tsx - // filter: [{ brightness: 1.01 }] - // filter: [{ brightness: 0.8 }] + // filter: [] ``` -Brightness values must be non-negative. Unsupported units and negative values are ignored with a development warning. +Unsupported units, malformed drop shadows, and negative values where React Native requires non-negative amounts are ignored with a development warning. diff --git a/src/babel/plugin/visitors/className.test.ts b/src/babel/plugin/visitors/className.test.ts index 4e9c67c..d297b9a 100644 --- a/src/babel/plugin/visitors/className.test.ts +++ b/src/babel/plugin/visitors/className.test.ts @@ -38,6 +38,23 @@ describe("className visitor - basic transformation", () => { expect(output).toMatch(/filter:\s*\[\s*{\s*brightness:\s*1\.01/); }); + it("should transform composed and structured filters", () => { + const input = ` + import { View } from 'react-native'; + export function Component() { + return ; + } + `; + + const output = transform(input, undefined, true); + + expect(output).not.toContain("className"); + expect(output).toMatch(/blur:\s*8/); + expect(output).toMatch(/hueRotate:\s*["']45deg["']/); + expect(output).toMatch(/dropShadow:\s*{\s*offsetX:\s*0,\s*offsetY:\s*4/); + expect(output).toContain('color: "#00000080"'); + }); + it("should work with both tw and className in same file", () => { const input = ` import { tw } from '@mgcrea/react-native-tailwind'; diff --git a/src/index.ts b/src/index.ts index 62f16cb..d533065 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,7 +13,7 @@ export { mergeStyles } from "./utils/mergeStyles"; export { generateStyleKey } from "./utils/styleKey"; // Re-export types -export type { StyleObject } from "./types/core"; +export type { DropShadowStyle, FilterStyle, StyleObject } from "./types/core"; export type { NativeStyle, TwStyle } from "./types/runtime"; // Re-export colors @@ -38,6 +38,7 @@ export { // Re-export constants for customization export { ASPECT_RATIO_PRESETS } from "./parser/aspectRatio"; export { COLORS } from "./parser/colors"; +export { BLUR_SCALE, DROP_SHADOW_SCALE } from "./parser/filters"; export { INSET_SCALE, Z_INDEX_SCALE } from "./parser/layout"; export { SHADOW_SCALE } from "./parser/shadows"; export { SIZE_PERCENTAGES, SIZE_SCALE } from "./parser/sizing"; diff --git a/src/parser/filters.test.ts b/src/parser/filters.test.ts index 5f25994..d9305ee 100644 --- a/src/parser/filters.test.ts +++ b/src/parser/filters.test.ts @@ -1,47 +1,137 @@ import { describe, expect, it } from "vitest"; -import { parseFilter } from "./filters"; +import { applyOpacity } from "../utils/colorUtils"; +import { BLUR_SCALE, DROP_SHADOW_SCALE, parseFilter } from "./filters"; import { parseClassName } from "./index"; -describe("parseFilter - brightness", () => { - it("should parse numeric brightness values as percentages", () => { - expect(parseFilter("brightness-0")).toEqual({ filter: [{ brightness: 0 }] }); - expect(parseFilter("brightness-50")).toEqual({ filter: [{ brightness: 0.5 }] }); - expect(parseFilter("brightness-100")).toEqual({ filter: [{ brightness: 1 }] }); +describe("parseFilter - percentage filters", () => { + it("should parse numeric values as percentages", () => { expect(parseFilter("brightness-101")).toEqual({ filter: [{ brightness: 1.01 }] }); - expect(parseFilter("brightness-125")).toEqual({ filter: [{ brightness: 1.25 }] }); - expect(parseFilter("brightness-200")).toEqual({ filter: [{ brightness: 2 }] }); + expect(parseFilter("contrast-125")).toEqual({ filter: [{ contrast: 1.25 }] }); + expect(parseFilter("grayscale-50")).toEqual({ filter: [{ grayscale: 0.5 }] }); + expect(parseFilter("invert-25")).toEqual({ filter: [{ invert: 0.25 }] }); + expect(parseFilter("saturate-150")).toEqual({ filter: [{ saturate: 1.5 }] }); + expect(parseFilter("sepia-75")).toEqual({ filter: [{ sepia: 0.75 }] }); }); - it("should parse arbitrary numeric brightness values", () => { + it("should parse bare full-effect utilities", () => { + expect(parseFilter("grayscale")).toEqual({ filter: [{ grayscale: 1 }] }); + expect(parseFilter("invert")).toEqual({ filter: [{ invert: 1 }] }); + expect(parseFilter("sepia")).toEqual({ filter: [{ sepia: 1 }] }); + }); + + it("should parse arbitrary numeric values", () => { expect(parseFilter("brightness-[1.01]")).toEqual({ filter: [{ brightness: 1.01 }] }); - expect(parseFilter("brightness-[.5]")).toEqual({ filter: [{ brightness: 0.5 }] }); - expect(parseFilter("brightness-[2]")).toEqual({ filter: [{ brightness: 2 }] }); + expect(parseFilter("contrast-[.5]")).toEqual({ filter: [{ contrast: 0.5 }] }); + expect(parseFilter("saturate-[2]")).toEqual({ filter: [{ saturate: 2 }] }); }); - it("should parse arbitrary percentage brightness values", () => { + it("should parse arbitrary percentage values", () => { expect(parseFilter("brightness-[80%]")).toEqual({ filter: [{ brightness: 0.8 }] }); - expect(parseFilter("brightness-[101%]")).toEqual({ filter: [{ brightness: 1.01 }] }); + expect(parseFilter("grayscale-[25%]")).toEqual({ filter: [{ grayscale: 0.25 }] }); + expect(parseFilter("sepia-[101%]")).toEqual({ filter: [{ sepia: 1.01 }] }); }); +}); - it("should reject unsupported or negative brightness values", () => { - expect(parseFilter("brightness-[-1]")).toBeNull(); - expect(parseFilter("brightness-[1.01px]")).toBeNull(); - expect(parseFilter("brightness-[abc]")).toBeNull(); - expect(parseFilter("brightness-auto")).toBeNull(); - expect(parseFilter("brightness-")).toBeNull(); +describe("parseFilter - blur", () => { + it("should expose the Tailwind blur scale", () => { + expect(BLUR_SCALE).toEqual({ none: 0, xs: 4, sm: 8, md: 12, lg: 16, xl: 24, "2xl": 40, "3xl": 64 }); }); - it("should return null for unrelated classes", () => { - expect(parseFilter("contrast-100")).toBeNull(); - expect(parseFilter("bg-white")).toBeNull(); - expect(parseFilter("")).toBeNull(); + it("should parse blur presets", () => { + expect(parseFilter("blur-none")).toEqual({ filter: [{ blur: 0 }] }); + expect(parseFilter("blur-xs")).toEqual({ filter: [{ blur: 4 }] }); + expect(parseFilter("blur-sm")).toEqual({ filter: [{ blur: 8 }] }); + expect(parseFilter("blur-3xl")).toEqual({ filter: [{ blur: 64 }] }); + }); + + it("should parse arbitrary pixel blur values", () => { + expect(parseFilter("blur-[2px]")).toEqual({ filter: [{ blur: 2 }] }); + expect(parseFilter("blur-[2.5]")).toEqual({ filter: [{ blur: 2.5 }] }); + }); +}); + +describe("parseFilter - hue rotate", () => { + it("should parse degree utilities", () => { + expect(parseFilter("hue-rotate-0")).toEqual({ filter: [{ hueRotate: "0deg" }] }); + expect(parseFilter("hue-rotate-45")).toEqual({ filter: [{ hueRotate: "45deg" }] }); + expect(parseFilter("-hue-rotate-90")).toEqual({ filter: [{ hueRotate: "-90deg" }] }); + }); + + it("should parse arbitrary deg and rad angles", () => { + expect(parseFilter("hue-rotate-[22.5deg]")).toEqual({ filter: [{ hueRotate: "22.5deg" }] }); + expect(parseFilter("hue-rotate-[-45deg]")).toEqual({ filter: [{ hueRotate: "-45deg" }] }); + expect(parseFilter("hue-rotate-[0.5rad]")).toEqual({ filter: [{ hueRotate: "0.5rad" }] }); + }); +}); + +describe("parseFilter - drop shadow", () => { + it("should expose and parse Tailwind drop-shadow presets", () => { + expect(Object.keys(DROP_SHADOW_SCALE)).toEqual(["xs", "sm", "md", "lg", "xl", "2xl", "none"]); + expect(parseFilter("drop-shadow-md")).toEqual({ filter: [{ dropShadow: DROP_SHADOW_SCALE.md }] }); + expect(parseFilter("drop-shadow-none")).toEqual({ filter: [{ dropShadow: DROP_SHADOW_SCALE.none }] }); + }); + + it("should apply opacity modifiers to preset drop shadows", () => { + expect(parseFilter("drop-shadow-xl/50")).toEqual({ + filter: [{ dropShadow: { ...DROP_SHADOW_SCALE.xl, color: applyOpacity("#000000", 50) } }], + }); + }); + + it("should parse arbitrary drop shadows", () => { + expect(parseFilter("drop-shadow-[0_4px_4px_#00000080]")).toEqual({ + filter: [{ dropShadow: { offsetX: 0, offsetY: 4, standardDeviation: 4, color: "#00000080" } }], + }); + expect(parseFilter("drop-shadow-[-2px_3px_#ff0000]")).toEqual({ + filter: [{ dropShadow: { offsetX: -2, offsetY: 3, color: "#ff0000" } }], + }); + }); + + it("should resolve custom colors in arbitrary drop shadows", () => { + expect(parseFilter("drop-shadow-[0_2px_3px_brand]", { brand: "#123456" })).toEqual({ + filter: [{ dropShadow: { offsetX: 0, offsetY: 2, standardDeviation: 3, color: "#123456" } }], + }); + }); +}); + +describe("parseFilter - composition and validation", () => { + it("should compose different filter functions", () => { + expect(parseClassName("blur-sm brightness-110 contrast-125 saturate-150")).toEqual({ + filter: [{ blur: 8 }, { brightness: 1.1 }, { contrast: 1.25 }, { saturate: 1.5 }], + }); + }); + + it("should use the last utility for duplicate filter functions", () => { + expect(parseClassName("brightness-110 blur-sm brightness-90")).toEqual({ + filter: [{ brightness: 0.9 }, { blur: 8 }], + }); }); - it("should integrate with the class parser", () => { + it("should clear preceding filters with filter-none", () => { + expect(parseClassName("brightness-110 blur-sm filter-none")).toEqual({ filter: [] }); + }); + + it("should compose native opacity with filters without a class collision", () => { expect(parseClassName("brightness-[1.01] opacity-80")).toEqual({ filter: [{ brightness: 1.01 }], opacity: 0.8, }); }); + + it("should reject unsupported or malformed values", () => { + expect(parseFilter("brightness-[-1]")).toBeNull(); + expect(parseFilter("contrast-[1.01px]")).toBeNull(); + expect(parseFilter("blur-[-1px]")).toBeNull(); + expect(parseFilter("blur-[50%]")).toBeNull(); + expect(parseFilter("hue-rotate-[45turn]")).toBeNull(); + expect(parseFilter("drop-shadow-[0_4px_-2px_#000000]")).toBeNull(); + expect(parseFilter("drop-shadow-[invalid]")).toBeNull(); + expect(parseFilter("brightness-auto")).toBeNull(); + }); + + it("should return null for unrelated classes", () => { + expect(parseFilter("opacity-50")).toBeNull(); + expect(parseFilter("bg-white")).toBeNull(); + expect(parseFilter("")).toBeNull(); + }); }); diff --git a/src/parser/filters.ts b/src/parser/filters.ts index 1f2fdc7..0b24f14 100644 --- a/src/parser/filters.ts +++ b/src/parser/filters.ts @@ -3,15 +3,40 @@ */ import type { StyleObject } from "../types"; +import type { DropShadowStyle, FilterStyle } from "../types/core"; +import { COLORS, applyOpacity, parseColorValue } from "../utils/colorUtils"; const NUMBER_PATTERN = String.raw`(?:\d+(?:\.\d*)?|\.\d+)`; +export const BLUR_SCALE: Record = { + none: 0, + xs: 4, + sm: 8, + md: 12, + lg: 16, + xl: 24, + "2xl": 40, + "3xl": 64, +}; + +export const DROP_SHADOW_SCALE: Record = { + xs: { offsetX: 0, offsetY: 1, standardDeviation: 1, color: applyOpacity(COLORS.black, 5) }, + sm: { offsetX: 0, offsetY: 1, standardDeviation: 2, color: applyOpacity(COLORS.black, 15) }, + md: { offsetX: 0, offsetY: 3, standardDeviation: 3, color: applyOpacity(COLORS.black, 12) }, + lg: { offsetX: 0, offsetY: 4, standardDeviation: 4, color: applyOpacity(COLORS.black, 15) }, + xl: { offsetX: 0, offsetY: 9, standardDeviation: 7, color: applyOpacity(COLORS.black, 10) }, + "2xl": { offsetX: 0, offsetY: 25, standardDeviation: 25, color: applyOpacity(COLORS.black, 15) }, + none: { offsetX: 0, offsetY: 0, standardDeviation: 0, color: "transparent" }, +}; + +type PercentageFilterName = "brightness" | "contrast" | "grayscale" | "invert" | "saturate" | "sepia"; + /** - * Parse a Tailwind brightness amount. - * Named numeric utilities are percentages (brightness-101 -> 1.01), while - * bracketed numbers are raw React Native amounts (brightness-[1.01] -> 1.01). + * Parse a Tailwind percentage-based filter amount. + * Named numeric utilities are percentages (contrast-125 -> 1.25), while + * bracketed numbers are raw React Native amounts (contrast-[1.25] -> 1.25). */ -function parseBrightnessAmount(value: string): number | null { +function parsePercentageAmount(value: string, filterName: PercentageFilterName): number | null { const arbitraryMatch = value.match(new RegExp(`^\\[(${NUMBER_PATTERN})(%)?\\]$`)); if (arbitraryMatch) { const amount = Number.parseFloat(arbitraryMatch[1]); @@ -22,7 +47,7 @@ function parseBrightnessAmount(value: string): number | null { /* v8 ignore next 5 */ if (process.env.NODE_ENV !== "production") { console.warn( - `[react-native-tailwind] Invalid arbitrary brightness value: ${value}. Only non-negative numbers and percentages are supported (e.g., [1.01], [80%]).`, + `[react-native-tailwind] Invalid arbitrary ${filterName} value: ${value}. Only non-negative numbers and percentages are supported (e.g., [1.01], [80%]).`, ); } return null; @@ -35,15 +60,152 @@ function parseBrightnessAmount(value: string): number | null { return null; } +function parseBlurAmount(value: string): number | null { + const scaleValue = BLUR_SCALE[value]; + if (scaleValue !== undefined) { + return scaleValue; + } + + const arbitraryMatch = value.match(new RegExp(`^\\[(${NUMBER_PATTERN})(?:px)?\\]$`)); + if (arbitraryMatch) { + return Number.parseFloat(arbitraryMatch[1]); + } + + if (value.startsWith("[") && value.endsWith("]")) { + /* v8 ignore next 5 */ + if (process.env.NODE_ENV !== "production") { + console.warn( + `[react-native-tailwind] Invalid arbitrary blur value: ${value}. Only non-negative pixel values are supported (e.g., [2px], [2.5]).`, + ); + } + } + + return null; +} + +function parseHueRotateAmount(value: string, isNegative: boolean): string | null { + const arbitraryMatch = value.match(new RegExp(`^\\[(-?${NUMBER_PATTERN})(deg|rad)\\]$`)); + if (arbitraryMatch) { + const amount = Number.parseFloat(arbitraryMatch[1]); + return `${isNegative ? -amount : amount}${arbitraryMatch[2]}`; + } + + if (value.startsWith("[") && value.endsWith("]")) { + /* v8 ignore next 5 */ + if (process.env.NODE_ENV !== "production") { + console.warn( + `[react-native-tailwind] Invalid arbitrary hue-rotate value: ${value}. Only deg and rad angles are supported (e.g., [45deg], [0.5rad]).`, + ); + } + return null; + } + + if (new RegExp(`^${NUMBER_PATTERN}$`).test(value)) { + const amount = Number.parseFloat(value); + return `${isNegative ? -amount : amount}deg`; + } + + return null; +} + +function parseDropShadowLength(value: string, allowNegative: boolean): number | null { + const match = value.match(new RegExp(`^(${allowNegative ? "-?" : ""}${NUMBER_PATTERN})(?:px)?$`)); + return match ? Number.parseFloat(match[1]) : null; +} + +function parseDropShadowAmount(value: string, customColors?: Record): DropShadowStyle | null { + const opacityMatch = value.match(/^(.+)\/(\d+)$/); + if (opacityMatch) { + const preset = DROP_SHADOW_SCALE[opacityMatch[1]]; + const opacity = Number.parseInt(opacityMatch[2], 10); + if (preset && opacity >= 0 && opacity <= 100) { + return { ...preset, color: applyOpacity(COLORS.black, opacity) }; + } + return null; + } + + const preset = DROP_SHADOW_SCALE[value]; + if (preset) { + return preset; + } + + if (value.startsWith("[") && value.endsWith("]")) { + const parts = value.slice(1, -1).split("_"); + if (parts.length === 3 || parts.length === 4) { + const offsetX = parseDropShadowLength(parts[0], true); + const offsetY = parseDropShadowLength(parts[1], true); + const hasDeviation = parts.length === 4; + const standardDeviation = hasDeviation ? parseDropShadowLength(parts[2], false) : undefined; + const colorToken = parts[parts.length - 1]; + const colorKey = colorToken.startsWith("#") ? `[${colorToken}]` : colorToken; + const color = parseColorValue(colorKey, customColors); + + if ( + offsetX !== null && + offsetY !== null && + (!hasDeviation || standardDeviation !== null) && + color !== null + ) { + return { offsetX, offsetY, standardDeviation: standardDeviation ?? undefined, color }; + } + } + + /* v8 ignore next 5 */ + if (process.env.NODE_ENV !== "production") { + console.warn( + `[react-native-tailwind] Invalid arbitrary drop-shadow value: ${value}. Use [offsetX_offsetY_blur_color] with pixel lengths and a supported color (e.g., [0_4px_4px_#00000080]).`, + ); + } + } + + return null; +} + +function percentageFilterStyle(filterName: PercentageFilterName, amount: number): StyleObject { + return { filter: [{ [filterName]: amount } as FilterStyle] }; +} + /** * Parse filter classes. * @param cls - The class name to parse */ -export function parseFilter(cls: string): StyleObject | null { - if (cls.startsWith("brightness-")) { - const amount = parseBrightnessAmount(cls.substring(11)); +export function parseFilter(cls: string, customColors?: Record): StyleObject | null { + if (cls === "filter-none") { + return { filter: [] }; + } + + if (cls.startsWith("blur-")) { + const amount = parseBlurAmount(cls.substring(5)); + if (amount !== null) { + return { filter: [{ blur: amount }] }; + } + } + + if (cls.startsWith("drop-shadow-")) { + const dropShadow = parseDropShadowAmount(cls.substring(12), customColors); + if (dropShadow !== null) { + return { filter: [{ dropShadow }] }; + } + } + + const hueRotateMatch = cls.match(/^(-?)hue-rotate-(.+)$/); + if (hueRotateMatch) { + const amount = parseHueRotateAmount(hueRotateMatch[2], hueRotateMatch[1] === "-"); + if (amount !== null) { + return { filter: [{ hueRotate: amount }] }; + } + } + + if (cls === "grayscale" || cls === "invert" || cls === "sepia") { + return percentageFilterStyle(cls, 1); + } + + const percentageFilterMatch = cls.match(/^(brightness|contrast|grayscale|invert|saturate|sepia)-(.+)$/); + if (percentageFilterMatch) { + const filterName = percentageFilterMatch[1] as PercentageFilterName; + const amount = parsePercentageAmount(percentageFilterMatch[2], filterName); if (amount !== null) { - return { filter: [{ brightness: amount }] }; + return percentageFilterStyle(filterName, amount); } } diff --git a/src/parser/index.ts b/src/parser/index.ts index 1fbf197..e7d0d2f 100644 --- a/src/parser/index.ts +++ b/src/parser/index.ts @@ -64,7 +64,7 @@ export function parseClass(cls: string, customTheme?: CustomTheme): StyleObject (cls: string) => parseTypography(cls, customTheme?.fontFamily, customTheme?.fontSize), (cls: string) => parseSizing(cls, customTheme?.spacing), (cls: string) => parseShadow(cls, customTheme?.colors), - parseFilter, + (cls: string) => parseFilter(cls, customTheme?.colors), parseAspectRatio, (cls: string) => parseTransform(cls, customTheme?.spacing), ]; diff --git a/src/types/core.ts b/src/types/core.ts index 06ad056..0c592f3 100644 --- a/src/types/core.ts +++ b/src/types/core.ts @@ -18,7 +18,22 @@ export type TransformStyle = | { perspective?: number }; export type ShadowOffsetStyle = { width: number; height: number }; -export type FilterStyle = { brightness: number }; +export type DropShadowStyle = { + offsetX: number; + offsetY: number; + standardDeviation?: number; + color?: string; +}; +export type FilterStyle = + | { brightness: number } + | { blur: number } + | { contrast: number } + | { grayscale: number } + | { hueRotate: string } + | { invert: number } + | { saturate: number } + | { sepia: number } + | { dropShadow: DropShadowStyle }; export type StyleObject = { [key: string]: string | number | ShadowOffsetStyle | TransformStyle[] | FilterStyle[] | undefined; diff --git a/src/utils/mergeStyles.test.ts b/src/utils/mergeStyles.test.ts index 6a1a30b..9afa240 100644 --- a/src/utils/mergeStyles.test.ts +++ b/src/utils/mergeStyles.test.ts @@ -102,6 +102,29 @@ describe("mergeStyles", () => { }); }); + describe("filter array merging", () => { + it("should compose different filter functions", () => { + const target = { filter: [{ blur: 8 }] }; + const source = { filter: [{ brightness: 1.1 }] }; + expect(mergeStyles(target, source)).toEqual({ + filter: [{ blur: 8 }, { brightness: 1.1 }], + }); + }); + + it("should replace the same filter function with the last value", () => { + const target = { filter: [{ brightness: 1.1 }, { blur: 8 }] }; + const source = { filter: [{ brightness: 0.9 }] }; + expect(mergeStyles(target, source)).toEqual({ + filter: [{ brightness: 0.9 }, { blur: 8 }], + }); + }); + + it("should clear filters when the source array is empty", () => { + const target = { filter: [{ brightness: 1.1 }, { blur: 8 }] }; + expect(mergeStyles(target, { filter: [] })).toEqual({ filter: [] }); + }); + }); + describe("mixed properties", () => { it("should handle mix of standard and transform properties", () => { const target = { margin: 4, transform: [{ rotate: "45deg" }] }; diff --git a/src/utils/mergeStyles.ts b/src/utils/mergeStyles.ts index 79144d3..3264f26 100644 --- a/src/utils/mergeStyles.ts +++ b/src/utils/mergeStyles.ts @@ -1,9 +1,9 @@ /** * Smart merge utility for StyleObject values - * Handles transform arrays with "last wins" semantics for same transform types + * Handles transform and filter arrays with "last wins" semantics for duplicate function types */ -import type { StyleObject, TransformStyle } from "../types/core"; +import type { FilterStyle, StyleObject, TransformStyle } from "../types/core"; /** * Get the transform type key from a transform object @@ -49,8 +49,37 @@ function mergeTransforms(target: TransformStyle[], source: TransformStyle[]): Tr return result; } +function getFilterType(filter: FilterStyle): string { + return Object.keys(filter)[0]; +} + /** - * Merge two StyleObject instances, handling transform arrays specially + * Merge native filter arrays, composing different filter functions while the + * last utility wins for duplicate filter types. + */ +function mergeFilters(target: FilterStyle[], source: FilterStyle[]): FilterStyle[] { + if (source.length === 0) { + return []; + } + + const result: FilterStyle[] = [...target]; + + for (const sourceFilter of source) { + const sourceType = getFilterType(sourceFilter); + const existingIndex = result.findIndex((filter) => getFilterType(filter) === sourceType); + + if (existingIndex !== -1) { + result[existingIndex] = sourceFilter; + } else { + result.push(sourceFilter); + } + } + + return result; +} + +/** + * Merge two StyleObject instances, handling transform and filter arrays specially * * @param target - The target object to merge into (mutated) * @param source - The source object to merge from @@ -93,8 +122,16 @@ export function mergeStyles(target: StyleObject, source: StyleObject): StyleObje // No existing array, just assign target.transform = sourceTransforms; } + } else if (key === "filter" && Array.isArray(sourceValue)) { + const sourceFilters = sourceValue as FilterStyle[]; + const targetValue = target.filter; + if (Array.isArray(targetValue)) { + target.filter = mergeFilters(targetValue, sourceFilters); + } else { + target.filter = sourceFilters; + } } else { - // Standard Object.assign behavior for non-transform properties + // Standard Object.assign behavior for scalar and object properties target[key] = sourceValue; } } From a5e29fc00b21b243a54f8561ef7a488d2da420e5 Mon Sep 17 00:00:00 2001 From: Evan Simpson <25159851+e-simpson@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:35:07 -0400 Subject: [PATCH 3/4] fix: match Tailwind filter composition order --- src/parser/filters.test.ts | 18 +++++++++++++++++- src/parser/index.ts | 6 ++++++ src/utils/mergeStyles.test.ts | 19 ++++++++++++++++--- src/utils/mergeStyles.ts | 25 ++++++++++++++++++++++++- 4 files changed, 63 insertions(+), 5 deletions(-) diff --git a/src/parser/filters.test.ts b/src/parser/filters.test.ts index d9305ee..704cac2 100644 --- a/src/parser/filters.test.ts +++ b/src/parser/filters.test.ts @@ -103,7 +103,7 @@ describe("parseFilter - composition and validation", () => { it("should use the last utility for duplicate filter functions", () => { expect(parseClassName("brightness-110 blur-sm brightness-90")).toEqual({ - filter: [{ brightness: 0.9 }, { blur: 8 }], + filter: [{ blur: 8 }, { brightness: 0.9 }], }); }); @@ -111,6 +111,22 @@ describe("parseFilter - composition and validation", () => { expect(parseClassName("brightness-110 blur-sm filter-none")).toEqual({ filter: [] }); }); + it("should let filter-none win regardless of class order", () => { + expect(parseClassName("filter-none brightness-110 blur-sm")).toEqual({ filter: [] }); + }); + + it("should compose filters in Tailwind's canonical order", () => { + expect(parseClassName("drop-shadow-md sepia hue-rotate-45 brightness-110 blur-sm")).toEqual({ + filter: [ + { blur: 8 }, + { brightness: 1.1 }, + { hueRotate: "45deg" }, + { sepia: 1 }, + { dropShadow: DROP_SHADOW_SCALE.md }, + ], + }); + }); + it("should compose native opacity with filters without a class collision", () => { expect(parseClassName("brightness-[1.01] opacity-80")).toEqual({ filter: [{ brightness: 1.01 }], diff --git a/src/parser/index.ts b/src/parser/index.ts index e7d0d2f..3b99b92 100644 --- a/src/parser/index.ts +++ b/src/parser/index.ts @@ -42,6 +42,12 @@ export function parseClassName(className: string, customTheme?: CustomTheme): St mergeStyles(style, parsedStyle); } + // Tailwind emits filter-none after composable filter utilities, so it wins + // regardless of the order of classes in markup. + if (classes.includes("filter-none")) { + style.filter = []; + } + return style; } diff --git a/src/utils/mergeStyles.test.ts b/src/utils/mergeStyles.test.ts index 9afa240..c725487 100644 --- a/src/utils/mergeStyles.test.ts +++ b/src/utils/mergeStyles.test.ts @@ -104,8 +104,8 @@ describe("mergeStyles", () => { describe("filter array merging", () => { it("should compose different filter functions", () => { - const target = { filter: [{ blur: 8 }] }; - const source = { filter: [{ brightness: 1.1 }] }; + const target = { filter: [{ brightness: 1.1 }] }; + const source = { filter: [{ blur: 8 }] }; expect(mergeStyles(target, source)).toEqual({ filter: [{ blur: 8 }, { brightness: 1.1 }], }); @@ -115,7 +115,7 @@ describe("mergeStyles", () => { const target = { filter: [{ brightness: 1.1 }, { blur: 8 }] }; const source = { filter: [{ brightness: 0.9 }] }; expect(mergeStyles(target, source)).toEqual({ - filter: [{ brightness: 0.9 }, { blur: 8 }], + filter: [{ blur: 8 }, { brightness: 0.9 }], }); }); @@ -123,6 +123,19 @@ describe("mergeStyles", () => { const target = { filter: [{ brightness: 1.1 }, { blur: 8 }] }; expect(mergeStyles(target, { filter: [] })).toEqual({ filter: [] }); }); + + it("should use Tailwind's canonical order for composed filters", () => { + const target = { filter: [{ dropShadow: { offsetX: 0, offsetY: 1 } }, { sepia: 1 }] }; + const source = { filter: [{ hueRotate: "45deg" }, { contrast: 1.25 }] }; + expect(mergeStyles(target, source)).toEqual({ + filter: [ + { contrast: 1.25 }, + { hueRotate: "45deg" }, + { sepia: 1 }, + { dropShadow: { offsetX: 0, offsetY: 1 } }, + ], + }); + }); }); describe("mixed properties", () => { diff --git a/src/utils/mergeStyles.ts b/src/utils/mergeStyles.ts index 3264f26..f66c44f 100644 --- a/src/utils/mergeStyles.ts +++ b/src/utils/mergeStyles.ts @@ -53,6 +53,29 @@ function getFilterType(filter: FilterStyle): string { return Object.keys(filter)[0]; } +// Tailwind composes independent filter utilities into one declaration in this +// order. Native filter arrays are order-sensitive, so preserve the same order +// regardless of class ordering. +const FILTER_ORDER = [ + "blur", + "brightness", + "contrast", + "grayscale", + "hueRotate", + "invert", + "saturate", + "sepia", + "dropShadow", +] as const; + +function sortFilters(filters: FilterStyle[]): FilterStyle[] { + return filters.sort((left, right) => { + const leftIndex = FILTER_ORDER.indexOf(getFilterType(left) as (typeof FILTER_ORDER)[number]); + const rightIndex = FILTER_ORDER.indexOf(getFilterType(right) as (typeof FILTER_ORDER)[number]); + return leftIndex - rightIndex; + }); +} + /** * Merge native filter arrays, composing different filter functions while the * last utility wins for duplicate filter types. @@ -75,7 +98,7 @@ function mergeFilters(target: FilterStyle[], source: FilterStyle[]): FilterStyle } } - return result; + return sortFilters(result); } /** From a60a097077ab92658bbad3d5b94e1a817af51850 Mon Sep 17 00:00:00 2001 From: Evan Simpson <25159851+e-simpson@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:08:13 -0400 Subject: [PATCH 4/4] docs: clarify filter-none precedence --- docs/src/content/docs/reference/filters.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/src/content/docs/reference/filters.md b/docs/src/content/docs/reference/filters.md index 3ab15e8..a05c633 100644 --- a/docs/src/content/docs/reference/filters.md +++ b/docs/src/content/docs/reference/filters.md @@ -119,10 +119,11 @@ Different filter utilities compile into one ordered native filter array. If the // filter: [{ brightness: 0.9 }] ``` -Use `filter-none` to clear filters that appear before it: +Use `filter-none` to clear all composable filters on the same element. It wins regardless of class-string order, matching Tailwind's generated CSS: ```tsx // filter: [] + // filter: [] ``` Unsupported units, malformed drop shadows, and negative values where React Native requires non-negative amounts are ignored with a development warning.