diff --git a/.changepacks/changepack_log_tailwind_hover_active_order.json b/.changepacks/changepack_log_tailwind_hover_active_order.json new file mode 100644 index 000000000..6d748495f --- /dev/null +++ b/.changepacks/changepack_log_tailwind_hover_active_order.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Order standalone nonresponsive hover-capability guards within their selector group so active, focus and disabled styles can override hover. Keep responsive Tailwind hover and Devup selector prop ordering unchanged. No new build errors.", + "date": "2026-10-09T00:00:00.000Z" +} diff --git a/e2e/tailwind-hover-order.spec.ts b/e2e/tailwind-hover-order.spec.ts new file mode 100644 index 000000000..803106f74 --- /dev/null +++ b/e2e/tailwind-hover-order.spec.ts @@ -0,0 +1,259 @@ +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' + +import { expect, test } from '@playwright/test' + +import { + codeExtractWithoutSourceMap, + getCss, + importClassMap, + importFileMap, + importSheet, + registerTheme, +} from '../bindings/devup-ui-wasm/pkg/index.js' + +const tailwindRequire = createRequire( + new URL('../benchmark/next-tailwind/package.json', import.meta.url), +) +const { + compile, +}: typeof import('../benchmark/next-tailwind/node_modules/tailwindcss/dist/lib.mjs') = + tailwindRequire('tailwindcss') +const tailwindTheme = readFileSync( + tailwindRequire.resolve('tailwindcss/theme.css'), + 'utf8', +) +const cases = [ + { + classes: 'hover:bg-red-500 active:bg-blue-500', + winners: ['blue', 'blue'], + }, + { + classes: 'md:hover:bg-red-500 active:bg-blue-500', + winners: ['blue', 'red'], + }, + { + classes: 'hover:bg-red-500 focus:bg-green-500 active:bg-blue-500', + winners: ['blue', 'blue'], + }, + { + classes: 'hover:bg-red-500 disabled:bg-gray-500', + winners: ['gray', 'gray'], + }, + { + classes: 'dark:hover:bg-red-500 active:bg-blue-500', + winners: ['red', 'red'], + }, + { + classes: 'hover:bg-red-500 active:bg-blue-500', + props: + '_media={{"(hover: hover)": {_hover: {bgColor:"#EF4444"}}}} _active={{bgColor:"#3B82F6"}}', + winners: ['blue', 'blue'], + }, +] as const +const colors = { + red: ['oklch(0.637 0.237 25.331)', 'rgb(239, 68, 68)'], + blue: ['oklch(0.623 0.214 259.815)', 'rgb(59, 130, 246)'], + green: ['oklch(0.723 0.219 149.579)', 'rgb(34, 197, 94)'], + gray: ['oklch(0.551 0.027 264.364)', 'rgb(107, 114, 128)'], +} + +for (const singleCss of [true, false]) { + for (const fixture of [ + { + props: + '_hover={{bg:"red"}} _focus={{bg:"green"}} _active={{bg:"blue"}} _disabled={{bg:"gray"}}', + css: '.a:hover{background:red}.b:focus{background:green}.c:active{background:blue}.d:disabled{background:gray}', + }, + { + props: + 'bg={["white","black"]} _hover={{bg:["red",null,"orange"]}} _focus={{bg:"green"}} _active={{bg:"blue"}} _disabled={{bg:"gray"}}', + css: '.a{background:white}@media(min-width:480px){.b{background:black}}.c:hover{background:red}@media(min-width:768px){.d:hover{background:orange}}.e:focus{background:green}.f:active{background:blue}.g:disabled{background:gray}', + }, + ]) { + test(`Devup selector props unchanged, ${fixture.props}, singleCss=${singleCss}`, () => { + importSheet({}) + importClassMap({}) + importFileMap({}) + registerTheme({}) + const output = codeExtractWithoutSourceMap( + 'selector-props.tsx', + `import { Box } from '@devup-ui/react';\n`, + '@devup-ui/react', + 'df', + singleCss, + false, + false, + {}, + ) + try { + const actual = singleCss ? getCss(null, false) : output.css + const expected = singleCss + ? fixture.css + : fixture.css.replace(/\.([a-g])/g, '.a-$1') + expect(actual?.replace(/^\/\*[\s\S]*?\*\//, '')).toBe(expected) + } finally { + output.free() + } + }) + } +} + +test.describe('Hover capability guard', () => { + test.use({ hasTouch: true, isMobile: true }) + for (const singleCss of [true, false]) { + test(`touch input keeps the base background, singleCss=${singleCss}`, async ({ + page, + }) => { + const classes = 'bg-white hover:bg-red-500' + const native = await compile(`${tailwindTheme}\n@tailwind utilities;`) + const oracleCss = native.build(classes.split(' ')) + importSheet({}) + registerTheme({}) + const output = codeExtractWithoutSourceMap( + 'hover-capability.tsx', + `import { Box } from '@devup-ui/react';\n`, + '@devup-ui/react', + 'df', + singleCss, + false, + false, + {}, + ) + try { + const matched = output.code.match(/className="([^"]+)"/) + expect(matched).not.toBeNull() + const devupCss = singleCss + ? getCss(null, false) + : getCss(null, false) + output.css + expect(devupCss).toContain('(hover:hover)') + for (const [className, css] of [ + [classes, oracleCss], + [matched?.[1], devupCss], + ]) { + await page.setContent( + `
tap
`, + ) + expect( + await page.evaluate(() => matchMedia('(hover:hover)').matches), + ).toBe(false) + await page.locator('#target').tap() + await expect(page.locator('#target')).toHaveCSS( + 'background-color', + 'rgb(255, 255, 255)', + ) + } + } finally { + output.free() + } + }) + } +}) + +for (const singleCss of [true, false]) { + for (const fixture of cases) { + for (const [index, width] of [640, 1024].entries()) { + const label = 'props' in fixture ? 'explicit Devup guard: ' : '' + test(`${label}${fixture.classes}, ${width}px, singleCss=${singleCss}`, async ({ + page, + }, testInfo) => { + const disabled = fixture.classes.includes('disabled:') + const native = await compile(`${tailwindTheme}\n@tailwind utilities;`) + const oracleCss = native.build(fixture.classes.split(' ')) + importSheet({}) + registerTheme({}) + const props = + 'props' in fixture + ? fixture.props + : `className="${fixture.classes}" ${disabled ? 'disabled' : ''}` + const source = `import { Box } from '@devup-ui/react';\n` + const output = codeExtractWithoutSourceMap( + 'hover-order.tsx', + source, + '@devup-ui/react', + 'df', + singleCss, + false, + false, + {}, + ) + try { + const matched = output.code.match(/className="([^"]+)"/) + expect(matched).not.toBeNull() + if (!singleCss) expect(output.cssFile).toBeDefined() + const devupCss = singleCss + ? getCss(null, false) + : getCss(null, false) + output.css + expect(devupCss).toContain('(hover:hover)') + const expected = colors[fixture.winners[index]] + await page.setViewportSize({ width, height: 400 }) + await page.emulateMedia({ colorScheme: 'dark' }) + const observed = [] + for (const [name, classes, css, color] of [ + ['Tailwind', fixture.classes, oracleCss, expected[0]], + ['Devup', matched?.[1], devupCss, expected[1]], + ]) { + await page.setContent( + ``, + ) + await page.mouse.move(width - 1, 399) + const target = page.locator('#target') + const baseline = await target.evaluate( + (element) => getComputedStyle(element).backgroundColor, + ) + await target.hover() + if (fixture.classes.startsWith('md:') && width < 768) { + await expect(target).toHaveCSS('background-color', baseline) + } else if (!disabled) { + await expect(target).toHaveCSS( + 'background-color', + colors.red[name === 'Tailwind' ? 0 : 1], + ) + } + if (fixture.classes.includes('focus:')) { + await target.focus() + await expect(target).toHaveCSS( + 'background-color', + colors.green[name === 'Tailwind' ? 0 : 1], + ) + } + await page.mouse.down() + try { + const held = await target.evaluate((element) => ({ + color: getComputedStyle(element).backgroundColor, + hover: element.matches(':hover'), + active: element.matches(':active'), + focus: element.matches(':focus'), + disabled: element.matches(':disabled'), + hoverMedia: matchMedia('(hover:hover)').matches, + })) + expect(held.hover, name).toBe(true) + expect(held.active, name).toBe(true) + expect(held.hoverMedia, name).toBe(true) + expect(held.disabled, name).toBe(disabled) + expect(held.focus, name).toBe(!disabled) + observed.push({ name, held }) + expect(held.color, name).toBe(color) + if (name === 'Devup') { + const screenshot = testInfo.outputPath('held.png') + await page.screenshot({ path: screenshot }) + await testInfo.attach('held-Devup', { + path: screenshot, + contentType: 'image/png', + }) + } + } finally { + await page.mouse.up() + } + } + await testInfo.attach('cascade-evidence', { + body: JSON.stringify({ source, oracleCss, devupCss, observed }), + contentType: 'application/json', + }) + } finally { + output.free() + } + }) + } + } +} diff --git a/libs/sheet/src/lib.rs b/libs/sheet/src/lib.rs index 3f3ea2732..df2ac1e4f 100644 --- a/libs/sheet/src/lib.rs +++ b/libs/sheet/src/lib.rs @@ -871,7 +871,7 @@ impl StyleSheet { // Estimate ~64 bytes per property for pre-allocation let prop_count: usize = map.values().map(FxHashSet::len).sum(); let mut current_css = String::with_capacity(prop_count * 64); - let mut class_rules: Vec<(RuleOrder<'_>, &StyleSheetProperty)> = + let mut class_rules: Vec<(RuleOrder<'_>, Wrapper<'_>, &StyleSheetProperty)> = Vec::with_capacity(prop_count); let mut at_rules: Vec<(AtRuleOrder<'_>, Wrapper<'_>, &StyleSheetProperty)> = Vec::new(); let mut global_props: Vec> = Vec::new(); @@ -890,15 +890,33 @@ impl StyleSheet { query, selector, outer, - .. + file, }) => { + let wrapper = Wrapper { + level: *level, + at_rule: Some((outer.as_slice(), *kind, query.as_str())), + }; + let selector = selector.as_deref(); + if *level == 0 + && *kind == AtRuleKind::Media + && query == "(hover:hover)" + && outer.is_empty() + && file.is_none() + && selector.is_some() + { + class_rules.push(( + (selector_group(selector), *level, selector.unwrap_or("")), + wrapper, + prop, + )); + continue; + } let chain = outer .iter() .map(|rule| (rule.kind, rule.query.as_str())) .chain(std::iter::once((*kind, query.as_str()))) .map(|(kind, query)| (kind as u8, query_order(kind, query), query)) .collect(); - let selector = selector.as_deref(); at_rules.push(( ( chain, @@ -906,18 +924,26 @@ impl StyleSheet { *level, selector.unwrap_or(""), ), - Wrapper { - level: *level, - at_rule: Some((outer.as_slice(), *kind, query.as_str())), - }, + wrapper, prop, )); } Some(StyleSelector::Selector(selector)) => class_rules.push(( (selector_group(Some(selector)), *level, selector.as_str()), + Wrapper { + level: *level, + at_rule: None, + }, + prop, + )), + None => class_rules.push(( + (selector_group(None), *level, ""), + Wrapper { + level: *level, + at_rule: None, + }, prop, )), - None => class_rules.push(((selector_group(None), *level, ""), prop)), } } } @@ -927,25 +953,22 @@ impl StyleSheet { // Selector group (plain, then `SELECTOR_ORDER`) sorts before the breakpoint // level, so `:active` still follows a `:hover` set at a wider breakpoint while - // one selector's responsive values keep ascending. At-rules follow every plain - // rule so a condition beats the breakpoint values it overrides; among - // themselves they order by condition, then selector group, then level. - class_rules.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| prop_cmp(a.1, b.1))); + // one selector's responsive values keep ascending. Standalone level-0 hover + // guards share that order without losing their media wrapper. Other at-rules, + // including responsive hover guards, follow these rules and keep ordering by + // condition, then selector group, then level. + class_rules.sort_by(|a, b| { + a.0.cmp(&b.0) + .then_with(|| prop_cmp(a.2, b.2)) + .then_with(|| a.2.selector.cmp(&b.2.selector)) + }); at_rules.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| prop_cmp(a.2, b.2))); let mut open: Option<(Wrapper<'_>, Option)> = None; let mut open_rule: Option<&StyleSheetProperty> = None; for (wrapper, prop) in class_rules .iter() - .map(|((_, level, _), prop)| { - ( - Wrapper { - level: *level, - at_rule: None, - }, - *prop, - ) - }) + .map(|(_, wrapper, prop)| (*wrapper, *prop)) .chain(at_rules.iter().map(|(_, wrapper, prop)| (*wrapper, *prop))) { if open.as_ref().is_none_or(|(current, _)| *current != wrapper) { @@ -3333,6 +3356,10 @@ mod tests { } fn pipeline_css(theme: Theme, source: &str) -> String { + pipeline_css_in_mode(theme, source, true) + } + + fn pipeline_css_in_mode(theme: Theme, source: &str, single_css: bool) -> String { css::debug::set_debug(false); css::set_prefix(None); css::atom_hoist::set_atom_hoist(None); @@ -3346,19 +3373,28 @@ mod tests { ExtractOption { package: "@devup-ui/core".to_string(), css_dir: "@devup-ui/core".to_string(), - single_css: true, + single_css, import_main_css: false, import_aliases: std::collections::HashMap::new(), }, ) .unwrap(); - sheet.update_styles(&output.styles, "test.tsx", true); + sheet.update_styles(&output.styles, "test.tsx", single_css); + let filename = if single_css { None } else { Some("test.tsx") }; + let css = sheet.create_css(filename, false); + if !single_css { + assert_eq!(sheet.create_css(None, false), StyleSheet::create_header()); + assert_eq!( + sheet.create_css(Some("other.tsx"), false), + StyleSheet::create_header() + ); + } // Class names come from a process-wide counter; number them by first // appearance so the expected CSS only pins down structure and order. let mut names: Vec = vec![]; compile_regex(r"\.([A-Za-z_][\w-]*)") .replace_all( - sheet.create_css(None, false).split("*/").nth(1).unwrap(), + css.split("*/").nth(1).unwrap(), |caps: ®ex_lite::Captures| { let index = names.iter().position(|n| *n == caps[1]).unwrap_or_else(|| { names.push(caps[1].to_string()); @@ -3370,6 +3406,256 @@ mod tests { .into_owned() } + #[rstest] + #[case( + "hover:bg-red-500 active:bg-blue-500", + "@media(hover:hover){.c0:hover{background-color:#EF4444}}.c1:active{background-color:#3B82F6}" + )] + #[case( + "md:hover:bg-red-500 active:bg-blue-500", + ".c0:active{background-color:#3B82F6}@media(min-width:768px)and (hover:hover){.c1:hover{background-color:#EF4444}}" + )] + #[case( + "hover:bg-red-500 focus:bg-green-500 active:bg-blue-500", + "@media(hover:hover){.c0:hover{background-color:#EF4444}}.c1:focus{background-color:#22C55E}.c2:active{background-color:#3B82F6}" + )] + #[case( + "hover:bg-red-500 disabled:bg-gray-500", + "@media(hover:hover){.c0:hover{background-color:#EF4444}}.c1:disabled{background-color:#6B7280}" + )] + #[case( + "dark:hover:bg-red-500 active:bg-blue-500", + "@media(hover:hover){:root[data-theme=dark] .c0:hover{background-color:#EF4444}}.c1:active{background-color:#3B82F6}" + )] + #[serial] + fn tailwind_states_keep_css_order_when_hover_is_guarded( + #[case] classes: &str, + #[case] expected: &str, + #[values(true, false)] single_css: bool, + ) { + // Given the same utilities in either authored token order and CSS mode. + for tokens in [ + classes.to_string(), + classes + .split_whitespace() + .rev() + .collect::>() + .join(" "), + ] { + let source = format!(""); + // When extraction and sheet emission run together. + let css = pipeline_css_in_mode(Theme::default(), &source, single_css); + // Then the exact cascade order retains each original guard and selector. + assert_eq!(css, expected, "{source}, single_css={single_css}"); + } + } + + #[rstest] + #[case( + r"", + "@media(hover:hover){.c0:hover{background:red}}.c1:active{background:blue}" + )] + #[case( + r"", + ".c0:hover{background:red}.c1:focus{background:green}.c2:active{background:blue}.c3:disabled{background:gray}" + )] + #[case( + r"", + ".c0{background:white}@media(min-width:480px){.c1{background:black}}.c2:hover{background:red}@media(min-width:768px){.c3:hover{background:orange}}.c4:focus{background:green}.c5:active{background:blue}.c6:disabled{background:gray}" + )] + #[case( + r"", + ".c0:hover{background:red}@media(min-width:768px){.c1:hover{background:blue}}.c2:active{background:green}" + )] + #[serial] + #[allow(clippy::literal_string_with_formatting_args)] + fn devup_states_keep_css_order_when_guard_exception_is_applied( + #[case] source: &str, + #[case] expected: &str, + #[values(true, false)] single_css: bool, + ) { + // Given literal Devup state props, including the explicitly authored guard. + // When extraction emits the selected CSS mode. + let css = pipeline_css_in_mode(Theme::default(), source, single_css); + // Then only the standalone level-0 guard adopts ordinary state ordering. + assert_eq!(css, expected, "{source}, single_css={single_css}"); + } + + #[rstest] + #[case( + (2, StyleSelector::nest_at_rule(Some(&"hover".into()), AtRuleKind::Media, "(hover:hover)").unwrap()), + ".active:active{color:blue}@media(min-width:768px)and (hover:hover){.guard:hover{color:red}}" + )] + #[case( + (0, StyleSelector::nest_at_rule(Some(&"hover".into()), AtRuleKind::Media, "(hover:none)").unwrap()), + ".active:active{color:blue}@media(hover:none){.guard:hover{color:red}}" + )] + #[case( + (0, StyleSelector::nest_at_rule(Some(&"hover".into()), AtRuleKind::Media, "(prefers-reduced-motion:reduce)").unwrap()), + ".active:active{color:blue}@media(prefers-reduced-motion:reduce){.guard:hover{color:red}}" + )] + #[case( + (0, StyleSelector::nest_at_rule(Some(&"hover".into()), AtRuleKind::Media, "(hover:hover)and (min-width:900px)").unwrap()), + ".active:active{color:blue}@media(hover:hover)and (min-width:900px){.guard:hover{color:red}}" + )] + #[case( + (0, StyleSelector::nest_at_rule(Some(&"hover".into()), AtRuleKind::Supports, "(hover:hover)").unwrap()), + ".active:active{color:blue}@supports(hover:hover){.guard:hover{color:red}}" + )] + #[case( + (0, StyleSelector::nest_at_rule(Some(&"hover".into()), AtRuleKind::Container, "(hover:hover)").unwrap()), + ".active:active{color:blue}@container(hover:hover){.guard:hover{color:red}}" + )] + #[case( + (0, StyleSelector::At { + kind: AtRuleKind::Media, + query: "(hover:hover)".to_string(), + selector: Some("&:hover".to_string()), + outer: vec![AtRule { kind: AtRuleKind::Supports, query: "(display:grid)".to_string() }], + file: None, + }), + ".active:active{color:blue}@supports(display:grid){@media(hover:hover){.guard:hover{color:red}}}" + )] + #[case( + (0, StyleSelector::At { + kind: AtRuleKind::Media, + query: "(hover:hover)".to_string(), + selector: Some("body:hover".to_string()), + outer: vec![], + file: Some("global.tsx".to_string()), + }), + ".active:active{color:blue}@media(hover:hover){body:hover{color:red}}" + )] + #[case( + (0, StyleSelector::nest_at_rule(None, AtRuleKind::Media, "(hover:hover)").unwrap()), + ".active:active{color:blue}@media(hover:hover){.guard{color:red}}" + )] + #[serial] + #[allow(clippy::literal_string_with_formatting_args)] + fn conditions_keep_css_order_when_guard_has_other_constraints( + #[case] condition: (u8, StyleSelector), + #[case] expected: &str, + #[values(true, false)] single_css: bool, + ) { + // Given an ordinary active state and one excluded conditional shape. + let mut sheet = StyleSheet::default(); + let filename = if single_css { None } else { Some("test.tsx") }; + sheet.add_property( + "active", + "color", + 0, + "blue", + Some(&"active".into()), + None, + filename, + ); + sheet.add_property( + "guard", + "color", + condition.0, + "red", + Some(&condition.1), + None, + filename, + ); + // When the sheet emits its original condition-ordered tail. + let css = sheet.create_css(filename, false); + // Then the exact wrapper and its position after active remain unchanged. + assert_eq!(css.split("*/").nth(1).unwrap(), expected); + } + + #[rstest] + #[serial] + fn guarded_rules_merge_when_wrappers_and_selectors_match( + #[values(None, Some("ui"))] layer: Option<&str>, + #[values(true, false)] single_css: bool, + ) { + // Given multiple declarations, guarded classes, and wrapper transitions in o2. + let mut sheet = StyleSheet::default(); + let filename = if single_css { None } else { Some("test.tsx") }; + let guard = + StyleSelector::nest_at_rule(Some(&"hover".into()), AtRuleKind::Media, "(hover:hover)") + .unwrap(); + let motion = + StyleSelector::nest_at_rule(None, AtRuleKind::Media, "(prefers-reduced-motion:reduce)") + .unwrap(); + let focus = StyleSelector::from("focus"); + let active = StyleSelector::from("active"); + for (name, property, value, selector) in [ + ("base", "color", "black", None), + ("guard", "background", "red", Some(&guard)), + ("guard", "color", "white", Some(&guard)), + ("other", "color", "yellow", Some(&guard)), + ("focus", "color", "green", Some(&focus)), + ("active", "color", "blue", Some(&active)), + ("motion", "color", "gray", Some(&motion)), + ] { + sheet.add_property_with_layer( + name, + property, + 0, + value, + selector, + Some(2), + filename, + layer, + ); + } + // When emission crosses base, guard, state, and conditional wrappers. + let css = sheet.create_css(filename, false); + // Then declarations merge only within a matching rule and layer. + #[allow(clippy::literal_string_with_formatting_args)] + let body = ".base{color:black}@media(hover:hover){.guard:hover{background:red;color:white}.other:hover{color:yellow}}.focus:focus{color:green}.active:active{color:blue}@media(prefers-reduced-motion:reduce){.motion{color:gray}}"; + let expected = match layer { + Some(layer) => format!("@layer o2{{@layer {layer}{{{body}}}}}"), + None => format!("@layer o2{{{body}}}"), + }; + let declaration = if single_css { "@layer o2;" } else { "" }; + assert_eq!( + css.split("*/").nth(1).unwrap(), + format!("{declaration}{expected}") + ); + } + + #[rstest] + #[case( + "red", + ".same:hover{color:red}@media(hover:hover){.same:hover{color:red}}" + )] + #[case( + "blue", + "@media(hover:hover){.same:hover{color:blue}}.same:hover{color:red}" + )] + #[case( + "white", + ".same:hover{color:red}@media(hover:hover){.same:hover{color:white}}" + )] + #[serial] + #[allow(clippy::literal_string_with_formatting_args)] + fn guarded_and_ordinary_rules_stay_distinct_when_order_keys_tie( + #[case] guarded_value: &str, + #[case] expected: &str, + #[values(true, false)] reverse: bool, + ) { + // Given equal order keys, with either tied or distinct declaration values. + let mut sheet = StyleSheet::default(); + let guard = + StyleSelector::nest_at_rule(Some(&"hover".into()), AtRuleKind::Media, "(hover:hover)") + .unwrap(); + let ordinary = StyleSelector::from("hover"); + let mut rules = [(&guard, guarded_value), (&ordinary, "red")]; + if reverse { + rules.reverse(); + } + for (selector, value) in rules { + sheet.add_property("same", "color", 0, value, Some(selector), None, None); + } + // When sorting must resolve an otherwise identical rule key. + let css = sheet.create_css(None, false); + // Then the selector tie is deterministic without dropping either wrapper. + assert_eq!(css.split("*/").nth(1).unwrap(), expected); + } + #[test] #[serial] #[allow(clippy::literal_string_with_formatting_args)]