From eb2775d2ad21ec543354fd133b0da3a0452c7dda Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 07:42:20 -0700 Subject: [PATCH] fix(core): Tab paints the press for every pointer Under spec:AST-094 FR43 the press controller paints a mouse's or a pen's press too (data-astryx-press), not only a finger's. Tab painted its pressed image only inside @media (pointer: coarse), so a mouse press showed nothing but :active's colour, with no release fade. - Its image arms paint the controller's press for every pointer. - Its hover and pressed colours yield while the press is on, so a press never paints twice, and paint again over the release, so a hovering pointer's release lands on the tab's hover (as the shared overlay does). Checkbox and Radio are #8696's; Switch is #8681's. Test: Tab's compiled CSS paints both arms outside the coarse media. --- .changeset/tab-press-every-pointer.md | 7 ++ packages/core/src/TabList/Tab.tsx | 17 +++- .../pressEveryPointer.source-build.test.mjs | 96 +++++++++++++++++++ 3 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 .changeset/tab-press-every-pointer.md create mode 100644 packages/core/src/utils/pressEveryPointer.source-build.test.mjs diff --git a/.changeset/tab-press-every-pointer.md b/.changeset/tab-press-every-pointer.md new file mode 100644 index 0000000000000..83fe9a8c05eb5 --- /dev/null +++ b/.changeset/tab-press-every-pointer.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] TabList: a mouse or pen press on a tab paints the same press feedback a finger's does, and its release lands on the tab's hover tint. + +@cixzhang diff --git a/packages/core/src/TabList/Tab.tsx b/packages/core/src/TabList/Tab.tsx index fb1d9a14bc00a..650790957b7cb 100644 --- a/packages/core/src/TabList/Tab.tsx +++ b/packages/core/src/TabList/Tab.tsx @@ -157,20 +157,29 @@ const styles = stylex.create({ // on the tab instead, and paints as an image below. '@media (pointer: coarse)': 'transparent', }, + // While the press is on, the hover and pressed colours yield to the + // image (same shape as the arm above, so it outranks it). Over the + // release they paint again: a hovering pointer's hover sits beneath the + // fading image, so the release lands on it. + [stylex.when.ancestor('[data-astryx-press="on"]', tabScope)]: { + default: 'transparent', + '@media (hover: hover)': 'transparent', + '@media (pointer: coarse)': 'transparent', + }, }, // The touch press, as an image layer over the colour: an image change is // discrete, so the layer's colour transition cannot fade the onset in, // and the strength the tab's `pressedAlpha` arms own (1 while on, 1 → 0 - // over the release) is what moves. Coarse pointers only, like the colour - // arms above; see interactionOverlay.stylex.ts. + // over the release) is what moves. Every pointer: a mouse paints at once; + // see interactionOverlay.stylex.ts. backgroundImage: { default: null, [stylex.when.ancestor('[data-astryx-press="on"]', tabScope)]: { - default: null, + default: pressedOverlayImage, '@media (pointer: coarse)': pressedOverlayImage, }, [stylex.when.ancestor('[data-astryx-press="fading"]', tabScope)]: { - default: null, + default: pressedOverlayImage, '@media (pointer: coarse)': pressedOverlayImage, }, }, diff --git a/packages/core/src/utils/pressEveryPointer.source-build.test.mjs b/packages/core/src/utils/pressEveryPointer.source-build.test.mjs new file mode 100644 index 0000000000000..885194d343756 --- /dev/null +++ b/packages/core/src/utils/pressEveryPointer.source-build.test.mjs @@ -0,0 +1,96 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file pressEveryPointer.source-build.test.mjs + * @input Uses Babel, the StyleX plugin as the package stylesheet compiles it, + * and the sources of the controls that paint the press on a descendant + * @output Verifies Tab paints the controller's press for every pointer, not + * only inside `@media (pointer: coarse)` + * @position Regression test for `spec:AST-094` FR43 (one press model for + * every pointer) in the compiled CSS of those modules + */ + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {transformAsync} from '@babel/core'; +import stylexBabelPlugin from '@stylexjs/babel-plugin'; +import {describe, expect, it} from 'vitest'; +import stylexResolution from '../../../../scripts/stylex-module-resolution.cjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +async function compiledCss(fileName) { + const file = path.join(__dirname, '..', fileName); + const result = await transformAsync(await fs.readFile(file, 'utf8'), { + babelrc: false, + configFile: false, + filename: file, + presets: [ + ['@babel/preset-typescript', {isTSX: true, allExtensions: true}], + ['@babel/preset-react', {runtime: 'automatic'}], + ], + plugins: [ + [ + stylexBabelPlugin, + { + dev: false, + runtimeInjection: false, + genConditionalClasses: true, + treeshakeCompensation: true, + unstable_moduleResolution: stylexResolution.stylexModuleResolution, + }, + ], + ], + }); + return stylexBabelPlugin.processStylexRules( + result?.metadata?.stylex ?? [], + false, + ); +} + +/** + * The rules painting the press image on an arm, each with whether it sits + * inside `@media (pointer: coarse)`. + */ +function pressImageRules(css, arm) { + const out = []; + const media = /@media ([^{]+)\{((?:[^{}]*\{[^{}]*\})*)[^{}]*\}/g; + const coarseRanges = []; + for (const match of css.matchAll(media)) { + if (match[1].includes('pointer: coarse')) { + coarseRanges.push([match.index, match.index + match[0].length]); + } + } + for (const match of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) { + if ( + match[1].includes(`[data-astryx-press="${arm}"]`) && + /background-image:\s*var\(--_press-paint-image\)/.test(match[2]) + ) { + out.push({ + coarseOnly: coarseRanges.some( + ([from, to]) => match.index >= from && match.index < to, + ), + }); + } + } + return out; +} + +const CONTROLS = ['TabList/Tab.tsx']; + +describe('controls that paint the press on a descendant', () => { + for (const control of CONTROLS) { + it(`${control} paints both arms for every pointer, not only a coarse one`, async () => { + const css = await compiledCss(control); + for (const arm of ['on', 'fading']) { + const rules = pressImageRules(css, arm); + expect(rules.length, `${control} ${arm}`).toBeGreaterThan(0); + expect( + rules.some(rule => !rule.coarseOnly), + `${control} ${arm}`, + ).toBe(true); + } + }); + } +});