diff --git a/CHANGELOG.md b/CHANGELOG.md index e87ccf2..511ccc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Transform logical names in `transition-property`, keeping static duration, delay, timing-function and behavior lists aligned when a property expands into several physical names. Dynamic or inherited timing lists are retained with a warning when expansion cannot be safely aligned. + ## [0.7.0] - 2026-10-03 ### Fixed diff --git a/README.md b/README.md index f5ff2d0..36ebb6e 100644 --- a/README.md +++ b/README.md @@ -153,12 +153,14 @@ See the [configuration guide](https://github.com/oe/postcss-logical-polyfill/blo ## Compatibility and limitations - Transformations assume horizontal writing modes. Vertical writing modes are not inferred. -- Keyframes and certain other at-rules are left untouched. Logical property names in `transition` are transformed by the upstream processor; animation names and keyframe contents are not rewritten. +- Keyframes and certain other at-rules are left untouched. Logical property names in `transition` and `transition-property` are transformed; animation names and keyframe contents are not rewritten. - Expand CSS nesting before this plugin, using a tool such as `postcss-nested` or `postcss-nesting`. Unexpanded nested rules are left intact with a PostCSS warning. - Direction prefixes add selector specificity. Overrides in separate rules should use matching direction scopes. For subtrees with conflicting direction ancestors, write explicit direction-scoped selectors so the plugin can use the rightmost context. - Repeated physical properties within an optimized rule are resolved by `!important` priority, then source order. Overlapping common shorthands are retained in direction rules where needed to preserve their ordering relative to directional longhands. - Browser compatibility depends on the physical properties and values in the output. Transforming a logical name does not polyfill unrelated CSS features such as custom properties, scroll behavior or containment. +When `transition-property` expands a shorthand such as `margin-inline`, static companion timing lists are expanded to keep their original correspondence. Dynamic or inherited lists, and component lists extracted from function-valued `transition` shorthands, are preserved with a PostCSS warning when they cannot be aligned safely. Explicit timing longhands with static functions can be aligned. + See [how it works](https://github.com/oe/postcss-logical-polyfill/blob/main/docs/src/content/docs/guides/how-it-works.mdx) and [troubleshooting](https://github.com/oe/postcss-logical-polyfill/blob/main/docs/src/content/docs/guides/troubleshooting.mdx). ## Examples and documentation diff --git a/docs/src/content/docs/references/supported-properties.mdx b/docs/src/content/docs/references/supported-properties.mdx index 64ef0d5..dae13b4 100644 --- a/docs/src/content/docs/references/supported-properties.mdx +++ b/docs/src/content/docs/references/supported-properties.mdx @@ -390,3 +390,7 @@ Both `repeating-linear-gradient` and `repeating-radial-gradient` support all the right: 10px; } ``` + +## Transition property lists + +`transition-property` maps logical names to their physical counterparts. Shorthands such as `margin-inline` expand to both sides. Static duration, delay, timing-function and behavior lists are aligned with the expanded list. When runtime variables, inherited timing lists or function-valued `transition` shorthands make expansion ambiguous, the declaration is retained with a PostCSS warning. Explicit timing longhands with static functions can be aligned. diff --git a/package.json b/package.json index 826b331..ebeafcd 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "test": "vitest", "test:ci": "vitest run", "test:package": "node scripts/test-package.mjs", - "test:browser": "node scripts/test-browser.mjs", + "test:browser": "node scripts/test-browser.mjs && node scripts/test-browser-transitions.mjs", "test:coverage": "vitest run --coverage", "test:coverage:watch": "vitest --coverage", "test:coverage:ui": "vitest --coverage --ui", diff --git a/scripts/test-browser-transitions.mjs b/scripts/test-browser-transitions.mjs new file mode 100644 index 0000000..571288d --- /dev/null +++ b/scripts/test-browser-transitions.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { chromium } from 'playwright-core'; +const require = createRequire(import.meta.url); +const postcss = require('postcss'); +const plugin = require('../dist/index.js'); +const cases = [ + 'transition-property:margin-inline,opacity;transition-duration:1s,2s;transition-timing-function:linear', + 'transition:opacity 1s ease 100ms,color 2s linear 200ms;transition-property:margin-inline,opacity', + 'transition-duration:1s,2s!important;transition:color 3s;transition-property:margin-inline,opacity', + 'transition-property:margin-inline-start,opacity;transition-duration:1s,2s;transition-delay:100ms,200ms', + 'transition:opacity 1e3ms linear,color 2e3ms linear;transition-property:margin-inline,opacity', + 'transition-duration:1s,2s;transition:opacity 1s 2s 3s;transition-property:margin-inline,opacity', + 'transition:opacity -100ms 1s linear,color -200ms 2s linear;transition-property:margin-inline,opacity', + 'transition-duration:1s,2s;transition:2 3s;transition-property:margin-inline,opacity', + 'transition-duration:1s,2s;transition:ease linear 3s;transition-property:margin-inline,opacity', + 'transition-duration:1s,2s;transition:normal allow-discrete 3s;transition-property:margin-inline,opacity', + 'transition-duration:1s,2s;transition:none 3s,opacity 2s;transition-property:margin-inline,opacity', + 'transition-duration:1s,2s;transition:opacity 3s,none 4s;transition-property:margin-inline,opacity', + 'transition-duration:1s,2s;transition:opacity calc(1s + 1s);transition-property:margin-inline,opacity', + 'transition-duration:1s,2s;transition:opacity 1s steps(0);transition-property:margin-inline,opacity', + 'transition-duration:1s,2s;transition:opacity 1s cubic-bezier(2,0,2,1);transition-property:margin-inline,opacity', + 'transition-property:MARGIN-INLINE-START,opacity;transition-duration:1s,2s' +].map(decls => `.x{margin-inline-start:0;opacity:1;${decls}}.x.end{margin-inline-start:100px;opacity:0}`); +const browser = await chromium.launch({ executablePath: process.env.CHROMIUM_BIN || '/usr/bin/chromium', args: ['--no-sandbox', '--disable-dev-shm-usage'] }); +try { + const page = await browser.newPage(); + async function transitions(css, direction) { + return page.evaluate(async ({ css, direction }) => { + document.body.innerHTML = ''; + const host = document.createElement('div'); + document.body.append(host); + const shadow = host.attachShadow({ mode: 'open' }); + shadow.innerHTML = `
x
`; + const el = shadow.querySelector('.x'); + getComputedStyle(el).marginLeft; + el.classList.add('end'); + getComputedStyle(el).marginLeft; + await new Promise(requestAnimationFrame); + return el.getAnimations().map(animation => { + const timing = animation.effect.getTiming(); + return { prop: animation.transitionProperty, duration: timing.duration, delay: timing.delay, easing: timing.easing }; + }).sort((a, b) => a.prop.localeCompare(b.prop)); + }, { css, direction }); + } + for (const css of cases) { + const compiled = (await postcss([plugin()]).process(css, { from: undefined })).css; + for (const direction of ['ltr', 'rtl']) { + const expected = await transitions(css, direction); + assert.equal(expected.length, 2, 'Both margin and opacity must actually transition'); + assert.deepEqual(await transitions(compiled, direction), expected, `${direction}: ${css}`); + } + } + console.log(`Browser transitions: ${cases.length * 2} native/compiled timing comparisons passed.`); +} finally { await browser.close(); } diff --git a/src/cascade.ts b/src/cascade.ts index 6bddfeb..a975490 100644 --- a/src/cascade.ts +++ b/src/cascade.ts @@ -11,6 +11,7 @@ const shorthands = new Map(); for (const family of ['margin', 'padding', 'scroll-margin', 'scroll-padding']) { shorthands.set(family, sides.map(side => `${family}-${side}`)); } +shorthands.set('transition', ['property', 'duration', 'timing-function', 'delay', 'behavior'].map(part => `transition-${part}`)); shorthands.set('inset', sides); shorthands.set('border', sides.flatMap(side => borderParts.map(part => `border-${side}-${part}`)).concat(borderImage)); for (const side of sides) shorthands.set(`border-${side}`, borderParts.map(part => `border-${side}-${part}`)); diff --git a/src/logical-properties.ts b/src/logical-properties.ts index 1e917dd..8c59e18 100644 --- a/src/logical-properties.ts +++ b/src/logical-properties.ts @@ -7,8 +7,9 @@ * * Enhanced with shim support for additional logical properties and values. */ -import postcss, { Declaration, Rule, Result } from 'postcss'; +import postcss, { Declaration, Rule, Result, Plugin } from 'postcss'; import logical from 'postcss-logical'; +import { transformTransitionProperties } from './transition-properties'; import { extendProcessors } from './logical-shim'; import { extendProcessorsWithExperimental, hasLogicalGradientDirection } from './logical-exp'; @@ -41,6 +42,7 @@ export function hasLogicalDeclaration(decl: Declaration): boolean { if (prop === 'background' || prop === 'background-image') return hasLogicalGradientDirection(decl.value); if (prop === 'float' || prop === 'clear') return ['inline-start', 'inline-end'].includes(decl.value.toLowerCase()); if (prop === 'resize') return ['inline', 'block'].includes(decl.value.toLowerCase()); + if (prop === 'transition-property') return postcss.list.comma(decl.value).some(name => supportedLogicalPropertiesSet.has(name.toLowerCase())); return supportedLogicalPropertiesSet.has(prop); } @@ -53,6 +55,7 @@ export async function applyLogicalTransformation(rule: Rule, direction: 'ltr' | tempRoot.append(rule.clone()); try { + await transformTransitionProperties(tempRoot.first as Rule, processor as Plugin, result, rule); const transformed = await postcss([processor]).process(tempRoot, { from: undefined }); for (const warning of transformed.warnings()) { if (result && !result.warnings().some(existing => existing.node === rule && existing.text === warning.text)) { @@ -142,4 +145,3 @@ export function analyzePropertyDifferences(ltrRule: Rule, rtlRule: Rule) { return { commonProps, ltrOnlyProps, rtlOnlyProps }; } - diff --git a/src/transition-properties.ts b/src/transition-properties.ts new file mode 100644 index 0000000..27fb19c --- /dev/null +++ b/src/transition-properties.ts @@ -0,0 +1,100 @@ +import postcss, { Declaration, Plugin, Result, Rule } from 'postcss'; +import valueParser from 'postcss-value-parser'; + +const defaults: Record = { + 'transition-property': 'all', 'transition-duration': '0s', + 'transition-delay': '0s', 'transition-timing-function': 'ease', + 'transition-behavior': 'normal' +}; +const mappingCache = new WeakMap>(); +const globalValues = /^(initial|inherit|unset|revert|revert-layer)$/i; +const identifier = /^(?:--|-?(?:[_a-z]|[^\x00-\x7f]|\\[^\n\r\f]))(?:[_a-z0-9-]|[^\x00-\x7f]|\\[^\n\r\f])*$/i; + +// null is invalid CSS; undefined means its component lists cannot be resolved safely. +function shorthand(value: string): Record | null | undefined { + const lists: Record = Object.fromEntries(Object.keys(defaults).map(prop => [prop, []])); + const items = postcss.list.comma(value); + for (const item of items) { + if (!item.trim()) return null; + const values = { ...defaults }; + const assigned = new Set(); + for (const node of valueParser(item).nodes) { + if (node.type === 'space' || node.type === 'comment') continue; + const token = valueParser.stringify(node); + if (node.type === 'word' && /^[-+]?(?:\d*\.?\d+|\d+\.)(?:e[-+]?\d+)?(?:ms|s)$/i.test(token)) { + const slot = !assigned.has('duration') && parseFloat(token) >= 0 ? 'duration' : 'delay'; + if (assigned.has(slot)) return null; + assigned.add(slot); + values[`transition-${slot}`] = token; + } else if (node.type === 'function') { + // Math and timing functions may depend on runtime values or be invalid. + // Keep the original property list rather than guessing their reset behavior. + return undefined; + } else if (/^(ease|ease-in|ease-out|ease-in-out|linear|step-start|step-end)$/i.test(token) && !assigned.has('easing')) { + assigned.add('easing'); + values['transition-timing-function'] = token; + } else if (/^(normal|allow-discrete)$/i.test(token) && !assigned.has('behavior')) { + assigned.add('behavior'); + values['transition-behavior'] = token; + } else if (node.type === 'word' && identifier.test(token) && !globalValues.test(token) && !/^default$/i.test(token)) { + if (assigned.has('property')) return null; + assigned.add('property'); + values['transition-property'] = token; + } else return null; + } + if (items.length > 1 && values['transition-property'].toLowerCase() === 'none') return null; + for (const prop of Object.keys(defaults)) lists[prop].push(values[prop]); + } + return Object.fromEntries(Object.entries(lists).map(([prop, values]) => [prop, values.join(', ')])); +} + +/** Expand the effective property list and keep its companion timing lists aligned. */ +export async function transformTransitionProperties(rule: Rule, processor: Plugin, result?: Result, warningNode = rule): Promise { + type State = { value: string; important: boolean; source?: Declaration; opaque?: boolean }; + const state = new Map(Object.entries(defaults).map(([prop, value]) => [prop, { value, important: false }])); + rule.walkDecls(decl => { + if (decl.parent !== rule) return; + const prop = decl.prop.toLowerCase(); + const values = prop === 'transition' ? shorthand(decl.value) : null; + if (prop === 'transition' && values === null && !globalValues.test(decl.value) && !/\b(?:var|env)\(/i.test(decl.value)) return; + const entries = prop === 'transition' ? Object.keys(defaults).map(key => [key, values?.[key] ?? decl.value]) : [[prop, decl.value]]; + for (const [key, value] of entries) { + const previous = state.get(key); + if (previous && (!previous.important || decl.important)) state.set(key, { value, important: !!decl.important, source: decl, opaque: prop === 'transition' && values === undefined }); + } + }); + const property = state.get('transition-property')!; + if (property.source?.prop.toLowerCase() !== 'transition-property') return; + const names = postcss.list.comma(property.value); + if (names.some(name => !/^(?:--)?[-\w]+$/.test(name))) return; + const mappings: string[][] = []; + for (const name of names) { + const cache = mappingCache.get(processor) ?? new Map(); + mappingCache.set(processor, cache); + if (cache.has(name)) { mappings.push(cache.get(name)!); continue; } + if (!Object.prototype.hasOwnProperty.call(processor.Declaration, name.toLowerCase())) { mappings.push([name]); continue; } + const transformed = await postcss([processor]).process(`.x{${name.toLowerCase()}:initial}`, { from: undefined }); + const first = transformed.root.first as Rule; + const mapped = first.nodes.filter(node => node.type === 'decl').map(decl => decl.prop); + cache.set(name, mapped); + mappings.push(mapped); + } + if (mappings.every((mapped, index) => mapped.length === 1 && mapped[0] === names[index])) return; + const warn = () => { + const text = 'Cannot safely align transition-property with unresolved or inherited timing lists; this declaration was left unchanged.'; + if (result && !result.warnings().some(w => w.node === warningNode && w.text === text)) result.warn(text, { node: warningNode }); + }; + const companions = [...state].filter(([prop]) => prop !== 'transition-property'); + if (mappings.some(mapped => mapped.length > 1) && companions.some(([, item]) => item.opaque || globalValues.test(item.value) || /\b(?:var|env)\(/i.test(item.value))) { warn(); return; } + const expanded = mappings.flat(); + if (expanded.length > 10000) { warn(); return; } + property.source.value = expanded.join(', '); + for (const [prop, item] of companions) { + const values = postcss.list.comma(item.value); + // A single repeated value remains aligned without adding a declaration. + if (values.length === 1) continue; + const aligned = mappings.flatMap((mapped, index) => mapped.map(() => values[index % values.length])); + if (aligned.join(', ') === values.join(', ')) continue; + rule.append(item.source!.clone({ prop, value: aligned.join(', '), important: item.important })); + } +} diff --git a/test/transition-property.test.ts b/test/transition-property.test.ts new file mode 100644 index 0000000..5e153f6 --- /dev/null +++ b/test/transition-property.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; +import postcss, { Rule } from 'postcss'; +import plugin from '../src'; +const process = (css: string) => postcss([plugin()]).process(css, { from: undefined }); +const values = (rule: Rule) => Object.fromEntries(rule.nodes.filter(node => node.type === 'decl').map(decl => [decl.prop, decl.value])); + +describe('transition-property', () => { + it('maps both directions and retains unrelated property names', async () => { + const result = await process('.x{transition-property:margin-inline-start,opacity,--Custom}'); + expect((result.root.nodes as Rule[]).map(values)).toEqual([ + { 'transition-property': 'margin-left, opacity, --Custom' }, + { 'transition-property': 'margin-right, opacity, --Custom' } + ]); + }); + it('maps additional shim properties as well as upstream properties', async () => { + const result = await process('.x{transition-property:scroll-margin-inline-start,overflow-inline,contain-intrinsic-inline-size}'); + expect((result.root.nodes as Rule[]).map(values)).toEqual([ + { 'transition-property': 'scroll-margin-left, overflow-x, contain-intrinsic-width' }, + { 'transition-property': 'scroll-margin-right, overflow-x, contain-intrinsic-width' } + ]); + }); + it('keeps property expansion aligned with independent timing lists', async () => { + const result = await process('.x{transition-property:margin-inline,opacity;transition-duration:1s,2s;transition-delay:100ms,200ms;transition-timing-function:steps(2, end),linear;transition-behavior:normal,allow-discrete}'); + for (const rule of (result.root.nodes as Rule[]).filter(r => r.selector.includes('dir='))) { + expect({ ...values(result.root.first as Rule), ...values(rule) }).toMatchObject({ + 'transition-duration': '1s, 1s, 2s', 'transition-delay': '100ms, 100ms, 200ms', + 'transition-timing-function': 'steps(2, end), steps(2, end), linear', + 'transition-behavior': 'normal, normal, allow-discrete' + }); + } + }); + it('uses the effective companion values from an earlier shorthand', async () => { + const result = await process('.x{transition:opacity 1s ease 100ms, color 2s linear 200ms;transition-property:margin-inline,opacity}'); + for (const rule of (result.root.nodes as Rule[]).filter(r => r.selector.includes('dir='))) { + expect(values(rule)['transition-duration']).toBe('1s, 1s, 2s'); + expect(values(rule)['transition-delay']).toBe('100ms, 100ms, 200ms'); + } + }); + it('aligns scientific-notation times from a shorthand', async () => { + const result = await process('.x{transition:opacity 1e3ms, color 2e3ms;transition-property:margin-inline,opacity}'); + for (const rule of (result.root.nodes as Rule[]).filter(r => r.selector.includes('dir='))) { + expect(values(rule)['transition-duration']).toBe('1e3ms, 1e3ms, 2e3ms'); + } + }); + it('aligns discrete-transition behavior from an earlier shorthand', async () => { + const result = await process('.x{transition:opacity 1s normal, display 2s allow-discrete;transition-property:margin-inline,display}'); + const rules = (result.root.nodes as Rule[]).filter(r => r.selector.includes('dir=')); + expect(rules).toHaveLength(2); + for (const rule of rules) { + expect(values(rule)['transition-behavior']).toBe('normal, normal, allow-discrete'); + expect(values(rule)['transition-duration']).toBe('1s, 1s, 2s'); + } + }); + it('ignores a shorthand with conflicting behavior keywords', async () => { + const result = await process('.x{transition-behavior:normal,allow-discrete;transition:opacity 1s normal allow-discrete;transition-property:margin-inline,display}'); + const rules = (result.root.nodes as Rule[]).filter(r => r.selector.includes('dir=')); + expect(rules).toHaveLength(2); + for (const rule of rules) expect(values(rule)['transition-behavior']).toBe('normal, normal, allow-discrete'); + }); + it('preserves important companion declarations', async () => { + const result = await process('.x{transition-duration:1s,2s!important;transition:color 3s;transition-property:margin-inline,opacity}'); + const rules = (result.root.nodes as Rule[]).filter(r => r.selector.includes('dir=')); + expect(rules).toHaveLength(2); + for (const rule of rules) { + const decl = rule.nodes.find(n => n.type === 'decl' && n.prop === 'transition-duration'); + expect(decl?.type === 'decl' && decl.important).toBe(true); + expect(values(rule)['transition-duration']).toBe('1s, 1s, 2s'); + } + }); + it('ignores invalid shorthand resets when aligning earlier valid timing lists', async () => { + const result = await process('.x{transition-duration:1s,2s;transition:opacity 1s 2s 3s;transition-property:margin-inline,opacity}'); + for (const rule of (result.root.nodes as Rule[]).filter(r => r.selector.includes('dir='))) { + expect(values(rule)['transition-duration']).toBe('1s, 1s, 2s'); + } + }); + it('retains a later shorthand reset', async () => { + const result = await process('.x{transition-property:margin-inline-start;transition:none}'); + expect(result.root.nodes).toHaveLength(1); + expect(values(result.root.first as Rule).transition).toBe('none'); + }); + it('keeps dynamic lists intact and warns once when alignment is unknown', async () => { + const result = await process('.x{transition-property:margin-inline,opacity;transition-duration:var(--durations)}'); + expect(values(result.root.first as Rule)['transition-property']).toBe('margin-inline,opacity'); + expect(result.warnings()).toHaveLength(1); + }); + it('maps one-to-one properties without interpreting dynamic timing values', async () => { + const result = await process('.x{transition-property:margin-inline-start,opacity;transition-duration:var(--durations)}'); + expect((result.root.nodes as Rule[]).some(r => values(r)['transition-property'] === 'margin-left, opacity')).toBe(true); + expect(result.warnings()).toHaveLength(0); + }); + it('does not modify ordinary or dynamic property lists', async () => { + for (const css of ['.x{transition-property:opacity,color}', '.x{transition-property:var(--properties)}']) expect((await process(css)).css).toBe(css); + }); + it.each(['2 3s', 'none 3s,opacity 2s', 'opacity 3s,none 4s'])('ignores invalid shorthand %s without resetting previous durations', async shorthand => { + const result = await process(`.x{transition-duration:1s,2s;transition:${shorthand};transition-property:margin-inline,opacity}`); + const rules = (result.root.nodes as Rule[]).filter(r => r.selector.includes('dir=')); + expect(rules).toHaveLength(2); + for (const rule of rules) expect(values(rule)['transition-duration']).toBe('1s, 1s, 2s'); + }); + it.each(['ease linear 3s', 'normal allow-discrete 3s'])('accepts a keyword as the property name after its component slot is filled: %s', async shorthand => { + const result = await process(`.x{transition-duration:1s,2s;transition:${shorthand};transition-property:margin-inline,opacity}`); + expect(result.css).not.toContain('transition-duration:1s, 1s, 2s'); + expect(result.warnings()).toHaveLength(0); + }); + it.each(['opacity calc(1s + 1s)', 'opacity 1s steps(0)', 'opacity 1s cubic-bezier(2,0,2,1)'])('preserves the list when function-valued shorthand cannot be resolved safely: %s', async shorthand => { + const css = `.x{transition-duration:1s,2s;transition:${shorthand};transition-property:margin-inline,opacity}`; + const result = await process(css); + expect(result.css).toBe(css); + expect(result.warnings()).toHaveLength(1); + }); + it('keeps unrelated rules intact, including fallback declarations and comments', async () => { + const css = '.x{color:red;/* fallback */color:unsupported();transition-property:opacity,color}'; + expect((await process(css)).css).toBe(css); + }); + it('recognizes case-insensitive logical property names', async () => { + const result = await process('.x{transition-property:MARGIN-INLINE-START,opacity}'); + expect((result.root.nodes as Rule[]).map(values)).toEqual([ + { 'transition-property': 'margin-left, opacity' }, + { 'transition-property': 'margin-right, opacity' } + ]); + }); +});