Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/src/content/docs/references/supported-properties.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
55 changes: 55 additions & 0 deletions scripts/test-browser-transitions.mjs
Original file line number Diff line number Diff line change
@@ -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 = `<style>${css}</style><div dir="${direction}"><div class="x">x</div></div>`;
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(); }
1 change: 1 addition & 0 deletions src/cascade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const shorthands = new Map<string, string[]>();
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}`));
Expand Down
6 changes: 4 additions & 2 deletions src/logical-properties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
}

Expand All @@ -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)) {
Expand Down Expand Up @@ -142,4 +145,3 @@ export function analyzePropertyDifferences(ltrRule: Rule, rtlRule: Rule) {
return { commonProps, ltrOnlyProps, rtlOnlyProps };
}


100 changes: 100 additions & 0 deletions src/transition-properties.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import postcss, { Declaration, Plugin, Result, Rule } from 'postcss';
import valueParser from 'postcss-value-parser';

const defaults: Record<string, string> = {
'transition-property': 'all', 'transition-duration': '0s',
'transition-delay': '0s', 'transition-timing-function': 'ease',
'transition-behavior': 'normal'
};
const mappingCache = new WeakMap<Plugin, Map<string, string[]>>();
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<string, string> | null | undefined {
const lists: Record<string, string[]> = 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<string>();
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<void> {
type State = { value: string; important: boolean; source?: Declaration; opaque?: boolean };
const state = new Map<string, State>(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<string, string[]>();
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 }));
}
}
Loading
Loading