mirror of
https://github.com/zoriya/v10.git
synced 2026-08-07 22:56:34 +00:00
fix(compiler): preserve selector-scoped tailwind vars
This commit is contained in:
@@ -411,6 +411,8 @@ interface EmitUnit {
|
||||
atRulePath: readonly string[];
|
||||
/** Selector text for the rule (e.g. `.play-button:hover` or `.play-button`). */
|
||||
selector: string;
|
||||
/** Whether this unit targets the base class selector with no selector variants. */
|
||||
isBaseSelector: boolean;
|
||||
/** Declarations to emit inside the rule. */
|
||||
declarations: readonly Declaration[];
|
||||
}
|
||||
@@ -438,6 +440,7 @@ function composeRules(
|
||||
entry = {
|
||||
atRulePath: u.atRulePath,
|
||||
selector: u.selector,
|
||||
isBaseSelector: u.isBaseSelector,
|
||||
declarations: [],
|
||||
declSet: new Set(),
|
||||
};
|
||||
@@ -536,7 +539,7 @@ function applyHoist(
|
||||
const rootValues = new Map<string, Set<string>>();
|
||||
const allValues = new Map<string, Set<string>>();
|
||||
for (const entry of merged.values()) {
|
||||
const isRoot = entry.atRulePath.length === 0;
|
||||
const isRoot = entry.atRulePath.length === 0 && entry.isBaseSelector;
|
||||
for (const d of entry.declarations) {
|
||||
if (!d.property.startsWith('--')) continue;
|
||||
const all = allValues.get(d.property) ?? new Set<string>();
|
||||
@@ -584,6 +587,7 @@ function applyHoist(
|
||||
rootEntry = {
|
||||
atRulePath: [],
|
||||
selector: rootSelector,
|
||||
isBaseSelector: true,
|
||||
declarations: [],
|
||||
declSet: new Set(),
|
||||
};
|
||||
@@ -834,6 +838,7 @@ function buildEmitUnit(className: string, branch: UtilityCssBranch): EmitUnit {
|
||||
return {
|
||||
atRulePath,
|
||||
selector: `.${className}${selectorTail}`,
|
||||
isBaseSelector: selectorTail.length === 0,
|
||||
declarations: branch.declarations,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -358,6 +358,17 @@ describe('emitCss — hoist', () => {
|
||||
expect(/\.skin\s*{[^}]*--media-error-dialog-transition-duration/.test(out.css)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not hoist a property that only appears on a selector variant', async () => {
|
||||
const fullscreenVariant = { kind: 'pseudo' as const, selector: ':fullscreen', raw: ':fullscreen' };
|
||||
const out = await emitCss({
|
||||
rules: [rule('a', [{ property: '--media-border-radius', value: '0' }], [fullscreenVariant])],
|
||||
hoist: { rootSelector: '.skin' },
|
||||
});
|
||||
if (out.kind !== 'merged') throw new Error('expected merged');
|
||||
expect(out.css).toMatch(/\.a:fullscreen\s*{[^}]*--media-border-radius:\s*0/);
|
||||
expect(/\.skin\s*{[^}]*--media-border-radius/.test(out.css)).toBe(false);
|
||||
});
|
||||
|
||||
it('only hoists CSS custom properties (no plain props)', async () => {
|
||||
const out = await emitCss({
|
||||
rules: [rule('a', [{ property: 'color', value: 'red' }]), rule('b', [{ property: 'color', value: 'red' }])],
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { compile as compileSource } from '../../compile';
|
||||
import { clearTokenModuleCache } from '../evaluator';
|
||||
import { tailwind } from '../plugin';
|
||||
|
||||
const MINIMAL_CSS = `
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-brand: oklch(0.7 0.2 250);
|
||||
}
|
||||
`;
|
||||
|
||||
const TOKENS = `
|
||||
import { cn } from '@videojs/utils/style';
|
||||
|
||||
export const styles = {
|
||||
controls: cn(
|
||||
'flex flex-wrap',
|
||||
'pointer-fine:transition-[scale,filter,opacity]',
|
||||
'motion-reduce:[--media-controls-transition-duration:50ms]',
|
||||
'contrast-more:[--media-surface-background-color:oklch(0_0_0)]',
|
||||
'[@media(prefers-reduced-transparency:reduce)]:[--media-surface-background-color:oklch(0_0_0)]',
|
||||
'@2xl/media-root:flex-nowrap'
|
||||
),
|
||||
button: cn(
|
||||
'group',
|
||||
'flex items-center',
|
||||
'disabled:opacity-50',
|
||||
'data-[availability=unsupported]:hidden',
|
||||
'aria-expanded:bg-current/10',
|
||||
'focus-visible:outline-current'
|
||||
),
|
||||
icon: cn(
|
||||
'hidden opacity-0',
|
||||
'group-data-paused:block',
|
||||
'group-data-paused:opacity-100',
|
||||
'group-not-data-paused:opacity-0'
|
||||
),
|
||||
thumbnail: cn(
|
||||
'absolute',
|
||||
'[left:var(--media-slider-pointer)]',
|
||||
'has-[[role=img]:not([data-hidden])]:opacity-100'
|
||||
),
|
||||
};
|
||||
`;
|
||||
|
||||
const SOURCE = `
|
||||
import { styles } from './tokens';
|
||||
|
||||
export function Fixture() {
|
||||
return (
|
||||
<FixtureRoot className="container p-4 [--media-popover-side-offset:0.5rem] [--media-controls-transition-duration:100ms] [color:var(--media-color-primary,oklch(1_0_0))] [&_video]:block [&:fullscreen]:[--media-border-radius:0]">
|
||||
<Controls className={styles.controls}>
|
||||
<Button className={styles.button}>
|
||||
<Icon className={styles.icon} />
|
||||
</Button>
|
||||
<div className={styles.thumbnail} />
|
||||
</Controls>
|
||||
</FixtureRoot>
|
||||
);
|
||||
}
|
||||
`;
|
||||
|
||||
let workDir: string;
|
||||
let sourcePath: string;
|
||||
let cssPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
workDir = mkdtempSync(join(tmpdir(), 'compiler-output-modes-'));
|
||||
sourcePath = writeFixture('skin.tsx', SOURCE);
|
||||
cssPath = writeFixture('tailwind.css', MINIMAL_CSS);
|
||||
writeFixture('tokens.ts', TOKENS);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearTokenModuleCache();
|
||||
});
|
||||
|
||||
describe('tailwind output modes', () => {
|
||||
it('inlines static token-backed utilities without emitting CSS assets', async () => {
|
||||
const result = await compileSource(SOURCE, {
|
||||
filename: sourcePath,
|
||||
configDir: workDir,
|
||||
config: { plugins: [tailwind({ mode: 'inline' })] },
|
||||
});
|
||||
|
||||
expect(result.assets).toEqual([]);
|
||||
expect(result.code).not.toContain('styles.');
|
||||
expect(result.code).toMatchInlineSnapshot(`
|
||||
"export function Fixture() {
|
||||
return (<FixtureRoot className="container p-4 [--media-popover-side-offset:0.5rem] [--media-controls-transition-duration:100ms] [color:var(--media-color-primary,oklch(1_0_0))] [&_video]:block [&:fullscreen]:[--media-border-radius:0]">
|
||||
<Controls className="flex flex-wrap pointer-fine:transition-[scale,filter,opacity] motion-reduce:[--media-controls-transition-duration:50ms] contrast-more:[--media-surface-background-color:oklch(0_0_0)] [@media(prefers-reduced-transparency:reduce)]:[--media-surface-background-color:oklch(0_0_0)] @2xl/media-root:flex-nowrap">
|
||||
<Button className="group flex items-center disabled:opacity-50 data-[availability=unsupported]:hidden aria-expanded:bg-current/10 focus-visible:outline-current">
|
||||
<Icon className="hidden opacity-0 group-data-paused:block group-data-paused:opacity-100 group-not-data-paused:opacity-0"/>
|
||||
</Button>
|
||||
<div className="absolute [left:var(--media-slider-pointer)] has-[[role=img]:not([data-hidden])]:opacity-100"/>
|
||||
</Controls>
|
||||
</FixtureRoot>);
|
||||
}
|
||||
"
|
||||
`);
|
||||
});
|
||||
|
||||
it('extracts static utilities to scoped vanilla CSS assets', async () => {
|
||||
const result = await compileSource(SOURCE, {
|
||||
filename: sourcePath,
|
||||
configDir: workDir,
|
||||
config: {
|
||||
plugins: [
|
||||
tailwind({
|
||||
mode: 'extract',
|
||||
input: cssPath,
|
||||
vars: { hoist: { rootSelector: '.media-test-skin' } },
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.assets).toHaveLength(1);
|
||||
expect(result.assets[0]).toMatchObject({ type: 'css', fileName: 'skin.css' });
|
||||
expect(result.code).toMatchInlineSnapshot(`
|
||||
"export function Fixture() {
|
||||
return (<FixtureRoot className="fixture-root">
|
||||
<Controls className="controls">
|
||||
<Button className="button group">
|
||||
<Icon className="icon"/>
|
||||
</Button>
|
||||
<div className="thumbnail"/>
|
||||
</Controls>
|
||||
</FixtureRoot>);
|
||||
}
|
||||
"
|
||||
`);
|
||||
|
||||
const css = result.assets[0]!.source;
|
||||
expect(css).not.toMatch(/\.media-test-skin\s*{[^}]*--media-border-radius/);
|
||||
expect(css).toMatchInlineSnapshot(`
|
||||
".media-test-skin {
|
||||
--default-transition-duration: 150ms;
|
||||
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--spacing: 0.25rem;
|
||||
}
|
||||
|
||||
.media-test-skin {
|
||||
--media-popover-side-offset: 0.5rem;
|
||||
}
|
||||
|
||||
.button {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 50%;
|
||||
}
|
||||
|
||||
.button:focus-visible {
|
||||
outline-color: currentcolor;
|
||||
}
|
||||
|
||||
.button[aria-expanded="true"] {
|
||||
background-color: color-mix(in oklab, currentcolor 10%, transparent);
|
||||
}
|
||||
|
||||
.button[data-availability="unsupported"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.fixture-root {
|
||||
--media-controls-transition-duration: 100ms;
|
||||
color: var(--media-color-primary,oklch(1 0 0));
|
||||
padding: calc(var(--spacing) * 4);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fixture-root video {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.fixture-root:fullscreen {
|
||||
--media-border-radius: 0;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: none;
|
||||
opacity: 0%;
|
||||
}
|
||||
|
||||
.icon:is(:where(.group):not(*[data-paused]) *) {
|
||||
opacity: 0%;
|
||||
}
|
||||
|
||||
.icon:is(:where(.group)[data-paused] *) {
|
||||
display: block;
|
||||
opacity: 100%;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
left: var(--media-slider-pointer);
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.thumbnail:has(*:is([role=img]:not([data-hidden]))) {
|
||||
opacity: 100%;
|
||||
}
|
||||
|
||||
@container media-root (width >= 42rem) {
|
||||
.controls {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (pointer: fine) {
|
||||
.controls {
|
||||
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
||||
transition-property: scale,filter,opacity;
|
||||
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-contrast: more) {
|
||||
.controls {
|
||||
--media-surface-background-color: oklch(0 0 0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.controls {
|
||||
--media-controls-transition-duration: 50ms;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-transparency:reduce) {
|
||||
.controls {
|
||||
--media-surface-background-color: oklch(0 0 0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (width >= 40rem) {
|
||||
.fixture-root {
|
||||
max-width: 40rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width >= 48rem) {
|
||||
.fixture-root {
|
||||
max-width: 48rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width >= 64rem) {
|
||||
.fixture-root {
|
||||
max-width: 64rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width >= 80rem) {
|
||||
.fixture-root {
|
||||
max-width: 80rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width >= 96rem) {
|
||||
.fixture-root {
|
||||
max-width: 96rem;
|
||||
}
|
||||
}"
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
function writeFixture(relative: string, content: string): string {
|
||||
const absolute = join(workDir, relative);
|
||||
writeFileSync(absolute, content, 'utf8');
|
||||
return absolute;
|
||||
}
|
||||
Reference in New Issue
Block a user