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..a05c633 --- /dev/null +++ b/docs/src/content/docs/reference/filters.md @@ -0,0 +1,129 @@ +--- +title: Filters +description: Apply native visual filters to views +--- + +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. Applying a filter also implies `overflow: hidden`, so descendants are clipped to the view's bounds. + +## Platform Support + +| 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 + + +``` + +## 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 + + + + +``` + +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 `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. diff --git a/src/babel/plugin/visitors/className.test.ts b/src/babel/plugin/visitors/className.test.ts index 0f9a484..d297b9a 100644 --- a/src/babel/plugin/visitors/className.test.ts +++ b/src/babel/plugin/visitors/className.test.ts @@ -23,6 +23,38 @@ 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 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 0bd46f6..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 @@ -24,6 +24,7 @@ export { parseAspectRatio, parseBorder, parseColor, + parseFilter, parseLayout, parseOutline, parsePlaceholderClass, @@ -37,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 new file mode 100644 index 0000000..704cac2 --- /dev/null +++ b/src/parser/filters.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from "vitest"; + +import { applyOpacity } from "../utils/colorUtils"; +import { BLUR_SCALE, DROP_SHADOW_SCALE, parseFilter } from "./filters"; +import { parseClassName } from "./index"; + +describe("parseFilter - percentage filters", () => { + it("should parse numeric values as percentages", () => { + expect(parseFilter("brightness-101")).toEqual({ filter: [{ brightness: 1.01 }] }); + 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 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("contrast-[.5]")).toEqual({ filter: [{ contrast: 0.5 }] }); + expect(parseFilter("saturate-[2]")).toEqual({ filter: [{ saturate: 2 }] }); + }); + + it("should parse arbitrary percentage values", () => { + expect(parseFilter("brightness-[80%]")).toEqual({ filter: [{ brightness: 0.8 }] }); + expect(parseFilter("grayscale-[25%]")).toEqual({ filter: [{ grayscale: 0.25 }] }); + expect(parseFilter("sepia-[101%]")).toEqual({ filter: [{ sepia: 1.01 }] }); + }); +}); + +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 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: [{ blur: 8 }, { brightness: 0.9 }], + }); + }); + + it("should clear preceding filters with filter-none", () => { + 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 }], + 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 new file mode 100644 index 0000000..0b24f14 --- /dev/null +++ b/src/parser/filters.ts @@ -0,0 +1,213 @@ +/** + * Filter utilities supported by React Native's filter style property. + */ + +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 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 parsePercentageAmount(value: string, filterName: PercentageFilterName): 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 ${filterName} 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; +} + +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, 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 percentageFilterStyle(filterName, amount); + } + } + + return null; +} diff --git a/src/parser/index.ts b/src/parser/index.ts index 0e89fcc..3b99b92 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"; @@ -41,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; } @@ -63,6 +70,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), + (cls: string) => parseFilter(cls, customTheme?.colors), parseAspectRatio, (cls: string) => parseTransform(cls, customTheme?.spacing), ]; @@ -87,6 +95,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..0c592f3 100644 --- a/src/types/core.ts +++ b/src/types/core.ts @@ -18,9 +18,26 @@ export type TransformStyle = | { perspective?: number }; export type ShadowOffsetStyle = { width: number; height: 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[] | undefined; + [key: string]: string | number | ShadowOffsetStyle | TransformStyle[] | FilterStyle[] | undefined; + filter?: FilterStyle[]; shadowOffset?: ShadowOffsetStyle; transform?: TransformStyle[]; }; diff --git a/src/utils/mergeStyles.test.ts b/src/utils/mergeStyles.test.ts index 6a1a30b..c725487 100644 --- a/src/utils/mergeStyles.test.ts +++ b/src/utils/mergeStyles.test.ts @@ -102,6 +102,42 @@ describe("mergeStyles", () => { }); }); + describe("filter array merging", () => { + it("should compose different filter functions", () => { + const target = { filter: [{ brightness: 1.1 }] }; + const source = { filter: [{ blur: 8 }] }; + 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: [{ blur: 8 }, { brightness: 0.9 }], + }); + }); + + it("should clear filters when the source array is empty", () => { + 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", () => { 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 043a6a3..f66c44f 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,60 @@ function mergeTransforms(target: TransformStyle[], source: TransformStyle[]): Tr return result; } +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 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 sortFilters(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 @@ -84,16 +136,25 @@ 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 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; } }