From e014e099ac6983bd6e32b431c742aa3d412abb68 Mon Sep 17 00:00:00 2001 From: Simek Date: Fri, 26 Dec 2025 15:18:09 +0100 Subject: [PATCH 1/3] feat: support majority of `filter` style properties --- src/UtilityParser.ts | 44 ++++++++++ src/__tests__/filter.spec.ts | 56 ++++++++++++ src/helpers.ts | 4 + src/resolve/filter.ts | 160 +++++++++++++++++++++++++++++++++++ src/resolve/transform.ts | 5 +- src/tw-config.ts | 13 ++- 6 files changed, 276 insertions(+), 6 deletions(-) create mode 100644 src/__tests__/filter.spec.ts create mode 100644 src/resolve/filter.ts diff --git a/src/UtilityParser.ts b/src/UtilityParser.ts index d8f229d..6e4a805 100644 --- a/src/UtilityParser.ts +++ b/src/UtilityParser.ts @@ -28,6 +28,15 @@ import pointerEvents from './resolve/pointer-events'; import userSelect from './resolve/user-select'; import textDecorationStyle from './resolve/text-decoration-style'; import { outlineOffset, outlineStyle, outlineWidth } from './resolve/outline'; +import { + filterBrightness, + filterContrast, + filterGrayscale, + filterHueRotate, + filterInvert, + filterSaturate, + filterSepia, +} from './resolve/filter'; export default class UtilityParser { private position = 0; @@ -376,6 +385,41 @@ export default class UtilityParser { if (style) return style; } + if (this.consumePeeked(`brightness-`)) { + style = filterBrightness(this.rest, this.context, theme?.brightness); + if (style) return style; + } + + if (this.consumePeeked(`contrast-`)) { + style = filterContrast(this.rest, this.context, theme?.contrast); + if (style) return style; + } + + if (this.consumePeeked(`saturate-`)) { + style = filterSaturate(this.rest, this.context, theme?.saturate); + if (style) return style; + } + + if (this.consumePeeked(`hue-rotate-`)) { + style = filterHueRotate(this.rest, this.context, theme?.hueRotate); + if (style) return style; + } + + if (this.consumePeeked(`grayscale`)) { + style = filterGrayscale(this.rest, this.context, theme?.grayscale); + if (style) return style; + } + + if (this.consumePeeked(`invert`)) { + style = filterInvert(this.rest, this.context, theme?.invert); + if (style) return style; + } + + if (this.consumePeeked(`sepia`)) { + style = filterSepia(this.rest, this.context, theme?.sepia); + if (style) return style; + } + h.warn(`\`${this.isNegative ? `-` : ``}${this.rest}\` unknown or invalid utility`); return null; } diff --git a/src/__tests__/filter.spec.ts b/src/__tests__/filter.spec.ts new file mode 100644 index 0000000..2e10d23 --- /dev/null +++ b/src/__tests__/filter.spec.ts @@ -0,0 +1,56 @@ +import { describe, test, expect } from '@jest/globals'; +import { create } from '../'; + +describe(`filter utilities`, () => { + let tw = create(); + beforeEach(() => (tw = create())); + + const cases: Array<[string, Record[]>]> = [ + // grayscale + [`grayscale`, { filter: [{ grayscale: 1 }] }], + [`grayscale-0`, { filter: [{ grayscale: 0 }] }], + [`grayscale-[50%]`, { filter: [{ grayscale: 0.5 }] }], + // invert + [`invert`, { filter: [{ invert: 1 }] }], + [`invert-0`, { filter: [{ invert: 0 }] }], + [`invert-[25%]`, { filter: [{ invert: 0.25 }] }], + // sepia + [`sepia`, { filter: [{ sepia: 1 }] }], + [`sepia-0`, { filter: [{ sepia: 0 }] }], + [`sepia-[0.75]`, { filter: [{ sepia: 0.75 }] }], + // contrast + [`contrast-125`, { filter: [{ contrast: 1.25 }] }], + [`contrast-[2.5]`, { filter: [{ contrast: 2.5 }] }], + // brightness + [`brightness-75`, { filter: [{ brightness: 0.75 }] }], + [`brightness-110`, { filter: [{ brightness: 1.1 }] }], + [`brightness-[1.75]`, { filter: [{ brightness: 1.75 }] }], + // saturate + [`saturate-0`, { filter: [{ saturate: 0 }] }], + [`saturate-[.75]`, { filter: [{ saturate: 0.75 }] }], + // hue rotate + [`hue-rotate-90`, { filter: [{ hueRotate: `90deg` }] }], + [`hue-rotate-[27deg]`, { filter: [{ hueRotate: `27deg` }] }], + [`hue-rotate-[3rad]`, { filter: [{ hueRotate: `3rad` }] }], + [`hue-rotate-[-270deg]`, { filter: [{ hueRotate: `-270deg` }] }], + // all values mix + [ + `grayscale contrast-25 brightness-25 invert sepia-25 saturate-75 hue-rotate-90`, + { + filter: [ + { grayscale: 1 }, + { contrast: 0.25 }, + { brightness: 0.25 }, + { invert: 1 }, + { sepia: 0.25 }, + { saturate: 0.75 }, + { hueRotate: `90deg` }, + ], + }, + ], + ]; + + test.each(cases)(`tw\`%s\` -> %s`, (utility, expected) => { + expect(tw.style(utility)).toEqual(expected); + }); +}); diff --git a/src/helpers.ts b/src/helpers.ts index 9c3f3e4..9c52e35 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -226,6 +226,10 @@ function unconfiggedStyleVal( return toStyleVal(number, unit, context); } +export function isArbitraryValue(value: string): boolean { + return value.startsWith(`[`) && value.endsWith(`]`); +} + function consoleWarn(...args: any[]): void { console.warn(...args); // eslint-disable-line no-console } diff --git a/src/resolve/filter.ts b/src/resolve/filter.ts new file mode 100644 index 0000000..4b7ea48 --- /dev/null +++ b/src/resolve/filter.ts @@ -0,0 +1,160 @@ +import type { ParseContext, Style, StyleIR } from '../types'; +import type { TwTheme } from '../tw-config'; +import { isArbitraryValue, parseNumericValue, parseStyleVal } from '../helpers'; + +export function filterBrightness( + value: string, + context: ParseContext = {}, + config?: TwTheme['brightness'], +): StyleIR | null { + const styleVal = getBaseFilterStyleValue(value, context, config?.[value]); + + return createStyle(`brightness`, styleVal); +} + +export function filterContrast( + value: string, + context: ParseContext = {}, + config?: TwTheme['contrast'], +): StyleIR | null { + const styleVal = getBaseFilterStyleValue(value, context, config?.[value]); + + return createStyle(`contrast`, styleVal); +} + +export function filterSaturate( + value: string, + context: ParseContext = {}, + config?: TwTheme['saturate'], +): StyleIR | null { + const styleVal = getBaseFilterStyleValue(value, context, config?.[value]); + + return createStyle(`saturate`, styleVal); +} + +export function filterGrayscale( + value: string, + context: ParseContext = {}, + config?: TwTheme['grayscale'], +): StyleIR | null { + const parsed = value.startsWith(`-`) ? value.slice(1) : `100`; + const styleVal = getPercentageFilterStyleValue(parsed, context, config?.[value]); + + return createStyle(`grayscale`, styleVal); +} + +export function filterInvert( + value: string, + context: ParseContext = {}, + config?: TwTheme['invert'], +): StyleIR | null { + const parsed = value.startsWith(`-`) ? value.slice(1) : `100`; + const styleVal = getPercentageFilterStyleValue(parsed, context, config?.[value]); + + return createStyle(`invert`, styleVal); +} + +export function filterSepia( + value: string, + context: ParseContext = {}, + config?: TwTheme['sepia'], +): StyleIR | null { + const parsed = value.startsWith(`-`) ? value.slice(1) : `100`; + const styleVal = getPercentageFilterStyleValue(parsed, context, config?.[value]); + + return createStyle(`sepia`, styleVal); +} + +export function filterHueRotate( + value: string, + context: ParseContext = {}, + config?: TwTheme['hueRotate'], +): StyleIR | null { + const configValue = config?.[value]; + let styleVal: string | number | null; + if (configValue) { + styleVal = parseStyleVal(configValue, context); + } else if (isArbitraryValue(value)) { + const parsed = parseNumericValue(value.slice(1, -1)); + styleVal = parsed ? `${parsed[0]}${parsed[1]}` : null; + } else { + const parsed = parseNumericValue(value); + styleVal = parsed ? `${parsed[0]}${parsed[1]}` : null; + } + + return createStyle(`hueRotate`, styleVal); +} + +function getBaseFilterStyleValue( + value: string, + context: ParseContext = {}, + configValue?: string, +): string | number | null { + if (configValue) { + return parseStyleVal(configValue, context); + } else if (isArbitraryValue(value)) { + const parsed = parseNumericValue(value.slice(1, -1)); + return parsed ? parsed[0] : null; + } else { + const parsed = parseNumericValue(value); + return parsed ? parsed[0] / 100 : null; + } +} + +function getPercentageFilterStyleValue( + value: string, + context: ParseContext = {}, + configValue?: string, +): string | number | null { + if (configValue) { + const parsed = parseStyleVal(configValue, context)?.toString().slice(0, -1); + return parsed ? parseInt(parsed) / 100 : null; + } else if (isArbitraryValue(value)) { + const parsed = parseNumericValue(value.slice(1, -1)); + if (parsed === null) { + return null; + } + if (Number.isInteger(parsed[0])) { + return parsed[0] / 100; + } + return parsed[0]; + } else { + const parsed = parseNumericValue(value); + if (parsed === null) { + return null; + } + if (Number.isInteger(parsed[0])) { + return parsed[0] / 100; + } + return parsed[0]; + } +} + +function createStyle( + filterType: string, + styleVal: string | number | null, +): StyleIR | null { + return { + kind: `dependent`, + complete(style) { + updateFilterStyle(style, filterType, styleVal); + }, + }; +} + +function updateFilterStyle( + style: Style, + key: string, + styleVal: string | number | null, +): void { + if (styleVal === null) { + return; + } + + const existingFilter = (style.filter || []) as Style[]; + if (Array.isArray(existingFilter) && existingFilter) { + style.filter = [...existingFilter, { [key]: styleVal }]; + } else { + style.filter = existingFilter; + } +} diff --git a/src/resolve/transform.ts b/src/resolve/transform.ts index efeb37f..b1d523a 100644 --- a/src/resolve/transform.ts +++ b/src/resolve/transform.ts @@ -3,6 +3,7 @@ import type { DependentStyle, ParseContext, Style, StyleIR } from '../types'; import { isString, Unit } from '../types'; import { complete, + isArbitraryValue, parseNumericValue, parseStyleVal, parseUnconfigged, @@ -246,10 +247,6 @@ function createStyle( }; } -function isArbitraryValue(value: string): boolean { - return value.startsWith(`[`) && value.endsWith(`]`); -} - function parseOriginValue( value: string | undefined, allowedPositions: OriginPosition[], diff --git a/src/tw-config.ts b/src/tw-config.ts index 4ca2c32..6f8c9f5 100644 --- a/src/tw-config.ts +++ b/src/tw-config.ts @@ -45,14 +45,23 @@ export interface TwTheme { transformOrigin?: Record; outlineOffset?: Record; outlineWidth?: Record; - extend?: Omit; - // + + brightness?: Record; + contrast?: Record; + grayscale?: Record; + saturate?: Record; + invert?: Record; + sepia?: Record; + hueRotate?: Record; + colors?: TwColors; backgroundColor?: TwColors; // bg- borderColor?: TwColors; // border- textColor?: TwColors; // text- textDecorationColor?: TwColors; // decoration- outlineColor?: TwColors; // outline- + + extend?: Omit; } export const PREFIX_COLOR_PROP_MAP = { From ddbede65ac8cdf64a5a88ff3be08b59ec6eb5a06 Mon Sep 17 00:00:00 2001 From: Bartosz Kaszubowski Date: Mon, 29 Dec 2025 21:10:43 +0100 Subject: [PATCH 2/3] use extracted helper in few more places --- src/resolve/color.ts | 4 ++-- src/resolve/flex.ts | 4 ++-- src/resolve/line-height.ts | 4 ++-- src/resolve/spacing.ts | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/resolve/color.ts b/src/resolve/color.ts index 766f887..404d27c 100644 --- a/src/resolve/color.ts +++ b/src/resolve/color.ts @@ -1,7 +1,7 @@ import type { ColorStyleType, Style, StyleIR } from '../types'; import type { TwColors } from '../tw-config'; import { isObject, isString } from '../types'; -import { warn } from '../helpers'; +import { isArbitraryValue, warn } from '../helpers'; export function color( type: ColorStyleType, @@ -23,7 +23,7 @@ export function color( if (value.startsWith(`[#`) || value.startsWith(`[rgb`) || value.startsWith(`[hsl`)) { color = value.slice(1, -1); // arbitrary named colors: `bg-[lemonchiffon]` - } else if (value.startsWith(`[`) && value.slice(1, -1).match(/^[a-z]{3,}$/)) { + } else if (isArbitraryValue(value) && value.slice(1, -1).match(/^[a-z]{3,}$/)) { color = value.slice(1, -1); } else { color = configColor(value, config) ?? ``; diff --git a/src/resolve/flex.ts b/src/resolve/flex.ts index 8b3a239..74d9818 100644 --- a/src/resolve/flex.ts +++ b/src/resolve/flex.ts @@ -1,6 +1,6 @@ import type { TwTheme } from '../tw-config'; import type { ParseContext, StyleIR } from '../types'; -import { getCompleteStyle, complete, parseStyleVal, unconfiggedStyle } from '../helpers'; +import { getCompleteStyle, complete, parseStyleVal, unconfiggedStyle, isArbitraryValue } from '../helpers'; export function flexGrowShrink( type: 'Grow' | 'Shrink', @@ -8,7 +8,7 @@ export function flexGrowShrink( config?: TwTheme['flexGrow'] | TwTheme['flexShrink'], ): StyleIR | null { value = value.replace(/^-/, ``); - if (value[0] === `[` && value.endsWith(`]`)) { + if (isArbitraryValue(value)) { value = value.slice(1, -1); } const configKey = value === `` ? `DEFAULT` : value; diff --git a/src/resolve/line-height.ts b/src/resolve/line-height.ts index adf1c00..ac3fe67 100644 --- a/src/resolve/line-height.ts +++ b/src/resolve/line-height.ts @@ -1,14 +1,14 @@ import type { TwTheme } from '../tw-config'; import type { StyleIR } from '../types'; import { Unit } from '../types'; -import { parseNumericValue, complete, toStyleVal } from '../helpers'; +import { parseNumericValue, complete, toStyleVal, isArbitraryValue } from '../helpers'; export default function lineHeight( value: string, config?: TwTheme['lineHeight'], ): StyleIR | null { const parseValue = - config?.[value] ?? (value.startsWith(`[`) ? value.slice(1, -1) : value); + config?.[value] ?? (isArbitraryValue(value) ? value.slice(1, -1) : value); const parsed = parseNumericValue(parseValue); if (!parsed) { diff --git a/src/resolve/spacing.ts b/src/resolve/spacing.ts index e5d4095..dc5d0ef 100644 --- a/src/resolve/spacing.ts +++ b/src/resolve/spacing.ts @@ -1,7 +1,7 @@ import type { TwTheme } from '../tw-config'; import type { Direction, ParseContext, StyleIR } from '../types'; import { Unit } from '../types'; -import { parseNumericValue, parseUnconfigged, toStyleVal } from '../helpers'; +import { isArbitraryValue, parseNumericValue, parseUnconfigged, toStyleVal } from '../helpers'; export default function spacing( type: 'margin' | 'padding', @@ -11,7 +11,7 @@ export default function spacing( config?: TwTheme['margin'] | TwTheme['padding'], ): StyleIR | null { let numericValue = ``; - if (value[0] === `[`) { + if (isArbitraryValue(value)) { numericValue = value.slice(1, -1); } else { const configValue = config?.[value]; From 5d3527ed7697d9f696f0345cf22597210b70b871 Mon Sep 17 00:00:00 2001 From: Bartosz Kaszubowski Date: Mon, 29 Dec 2025 21:13:24 +0100 Subject: [PATCH 3/3] fix formatting --- src/resolve/flex.ts | 8 +++++++- src/resolve/spacing.ts | 7 ++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/resolve/flex.ts b/src/resolve/flex.ts index 74d9818..1dd28a6 100644 --- a/src/resolve/flex.ts +++ b/src/resolve/flex.ts @@ -1,6 +1,12 @@ import type { TwTheme } from '../tw-config'; import type { ParseContext, StyleIR } from '../types'; -import { getCompleteStyle, complete, parseStyleVal, unconfiggedStyle, isArbitraryValue } from '../helpers'; +import { + getCompleteStyle, + complete, + parseStyleVal, + unconfiggedStyle, + isArbitraryValue, +} from '../helpers'; export function flexGrowShrink( type: 'Grow' | 'Shrink', diff --git a/src/resolve/spacing.ts b/src/resolve/spacing.ts index dc5d0ef..f9cd42d 100644 --- a/src/resolve/spacing.ts +++ b/src/resolve/spacing.ts @@ -1,7 +1,12 @@ import type { TwTheme } from '../tw-config'; import type { Direction, ParseContext, StyleIR } from '../types'; import { Unit } from '../types'; -import { isArbitraryValue, parseNumericValue, parseUnconfigged, toStyleVal } from '../helpers'; +import { + isArbitraryValue, + parseNumericValue, + parseUnconfigged, + toStyleVal, +} from '../helpers'; export default function spacing( type: 'margin' | 'padding',