diff --git a/CONTEXT.md b/CONTEXT.md index 52a48891..33ba4339 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -72,7 +72,7 @@ Native runtime: - Resolved styles subscribe to only dependencies they use, then invalidate cache entries on change. - Runtime dependencies are represented by `StyleDependency`: theme, dimensions, orientation, insets, font scale, RTL, adaptive themes, and variables. - Native style resolution filters rules by screen width, orientation, theme, RTL, active/focus/disabled state, and `data-*` props. -- Native post-processing adapts CSS concepts to RN shapes, including line-height multipliers, shadows, transforms, gradients, visibility, borders, outlines, and font variants. +- Native post-processing adapts CSS concepts to RN shapes, including line-height multipliers, shadows, transforms, gradients, visibility, borders, outlines, font variants, and filters. Web runtime: @@ -145,6 +145,9 @@ Important concepts: - Media queries drive dimensions, orientation, color scheme, platform, and native/web-specific metadata. - Important declarations are preserved as `importantProperties`. - Unsupported CSS features may be silently ignored on native. Prefer documenting support coverage over adding noisy runtime failures for every unsupported CSS construct. +- Tailwind composes `filter` from per-utility `--tw-*` variables and relies on `var(--x,)` empty fallbacks for unset parts, so `Var` resolves those to an empty string. Each filter function compiles to `rt.filterFn(name, amount, unit)` because `addMissingSpaces` would otherwise corrupt an inline `blur(${...}px)` template. +- Filter runtime support is platform-dependent: Android applies filters at the default release level (blur and drop-shadow need API 31+, and one blur in the chain sends the whole chain down that path), while iOS renders blur/grayscale/saturate/contrast/hue-rotate only behind the `enableSwiftUIBasedFilters` React Native feature flag — experimental in RN 0.83-0.86, canary in 0.87, absent before 0.83. +- `backdrop-filter` has no RN equivalent and is still dropped. Web visitor behavior: diff --git a/packages/uniwind/src/bundler/css-processor/functions.ts b/packages/uniwind/src/bundler/css-processor/functions.ts index 7c5d4bda..86d0b584 100644 --- a/packages/uniwind/src/bundler/css-processor/functions.ts +++ b/packages/uniwind/src/bundler/css-processor/functions.ts @@ -1,7 +1,23 @@ import type { CalcFor_DimensionPercentageFor_LengthValue, CalcFor_Length, CssColor, Function as FunctionType } from 'lightningcss' import { Logger } from '../logger' import type { ProcessorBuilder } from './processor' -import { pipe } from './utils' +import { pipe, roundToPrecision } from './utils' + +const FILTER_FUNCTIONS = new Set([ + 'blur', + 'brightness', + 'contrast', + 'grayscale', + 'hue-rotate', + 'invert', + 'opacity', + 'saturate', + 'sepia', +]) + +const FILTER_UNITS: Record = { + blur: 'px', +} export class Functions { private readonly logger = new Logger('Functions') @@ -86,21 +102,11 @@ export class Functions { return this.Processor.Color.processColor(color as CssColor) } - if ( - [ - 'blur', - 'brightness', - 'contrast', - 'grayscale', - 'hue-rotate', - 'invert', - 'opacity', - 'saturate', - 'sepia', - 'conic-gradient', - 'radial-gradient', - ].includes(fn.name) - ) { + if (FILTER_FUNCTIONS.has(fn.name)) { + return this.processFilterFunction(fn) + } + + if (['conic-gradient', 'radial-gradient'].includes(fn.name)) { // Not supported by RN return '""' } @@ -194,6 +200,15 @@ export class Functions { } } + private processFilterFunction(fn: FunctionType) { + const [argument] = fn.arguments + const amount = argument?.type === 'token' && argument.value.type === 'percentage' + ? roundToPrecision(argument.value.value, 2) + : this.Processor.CSS.processValue(fn.arguments) + + return `rt.filterFn("${fn.name}", ${amount}, "${FILTER_UNITS[fn.name] ?? ''}")` + } + private processColorMix(fn: FunctionType) { const tokens = fn.arguments .map(arg => diff --git a/packages/uniwind/src/bundler/css-processor/var.ts b/packages/uniwind/src/bundler/css-processor/var.ts index f257d348..1f94bfc6 100644 --- a/packages/uniwind/src/bundler/css-processor/var.ts +++ b/packages/uniwind/src/bundler/css-processor/var.ts @@ -7,10 +7,17 @@ export class Var { processVar(variable: Variable): string { const value = `vars[${JSON.stringify(variable.name.ident)}]?.(vars)` - if (!variable.fallback || variable.fallback.length === 0) { + if (!variable.fallback) { return value } + // `var(--x,)` declares an empty fallback, which Tailwind relies on to compose + // optional parts (filters), so it must resolve to an empty string, not `undefined`. + // The trailing space keeps consecutive parts separate tokens for serialization. + if (variable.fallback.length === 0) { + return `${value} ?? "" ` + } + const fallback = this.Processor.CSS.processValue(variable.fallback) return `${value} ?? ${fallback}` diff --git a/packages/uniwind/src/core/native/runtime.ts b/packages/uniwind/src/core/native/runtime.ts index 9ab99de7..d1b49a39 100644 --- a/packages/uniwind/src/core/native/runtime.ts +++ b/packages/uniwind/src/core/native/runtime.ts @@ -24,6 +24,7 @@ export const UniwindRuntime = { right: 0, }, colorMix, + filterFn: (name: string, amount: number | string, unit: string) => `${name}(${amount}${unit})`, pixelRatio: value => value * PixelRatio.get(), cubicBezier: () => '', lightDark: () => '', diff --git a/packages/uniwind/tests/native/styles-parsing/filters.test.tsx b/packages/uniwind/tests/native/styles-parsing/filters.test.tsx new file mode 100644 index 00000000..8639c752 --- /dev/null +++ b/packages/uniwind/tests/native/styles-parsing/filters.test.tsx @@ -0,0 +1,93 @@ +import * as React from 'react' +import View from '../../../src/components/native/View' +import type { RNStyle } from '../../../src/core/types' +import { renderUniwind } from '../utils' + +const filterOf = ({ filter }: RNStyle) => + typeof filter === 'string' + ? filter.replace(/\s+/g, ' ').trim() + : filter + +describe('Filters', () => { + test('Blur', () => { + const { getStylesFromId } = renderUniwind( + , + ) + + expect(filterOf(getStylesFromId('blur-md'))).toBe('blur(12px)') + }) + + test('Arbitrary blur', () => { + const { getStylesFromId } = renderUniwind( + , + ) + + expect(filterOf(getStylesFromId('blur-arbitrary'))).toBe('blur(7px)') + }) + + test('Amount filters', () => { + const { getStylesFromId } = renderUniwind( + + + + + + + + , + ) + + expect(filterOf(getStylesFromId('grayscale'))).toBe('grayscale(1)') + expect(filterOf(getStylesFromId('invert'))).toBe('invert(1)') + expect(filterOf(getStylesFromId('sepia'))).toBe('sepia(1)') + expect(filterOf(getStylesFromId('saturate'))).toBe('saturate(1.5)') + expect(filterOf(getStylesFromId('brightness'))).toBe('brightness(0.5)') + expect(filterOf(getStylesFromId('contrast'))).toBe('contrast(1.25)') + }) + + test('Hue rotate', () => { + const { getStylesFromId } = renderUniwind( + , + ) + + expect(filterOf(getStylesFromId('hue-rotate'))).toBe('hue-rotate(90deg)') + }) + + test('Combined filters keep CSS order', () => { + const { getStylesFromId } = renderUniwind( + , + ) + + expect(filterOf(getStylesFromId('combined'))).toBe('blur(12px) grayscale(1)') + }) +})