Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/tab-press-every-pointer.md
Original file line number Diff line number Diff line change
@@ -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
17 changes: 13 additions & 4 deletions packages/core/src/TabList/Tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
},
Expand Down
96 changes: 96 additions & 0 deletions packages/core/src/utils/pressEveryPointer.source-build.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
});
}
});