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 = `
`;
+ 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' }
+ ]);
+ });
+});