Skip to content
Merged
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/touch-style-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[chore] Internal: core's touch style keys stay literal, and a test pins every one to the touch presentation resolver's constants (`touchKeys`, internal to core). An imported `defineConsts` key compiles to a different StyleX priority than the same literal. No public export and no style changes.

@vjeux
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -864,6 +864,7 @@
"devDependencies": {
"@astryxdesign/a11y-spec": "workspace:*",
"@astryxdesign/cli": "workspace:*",
"@astryxdesign/interaction-harness": "workspace:*",
"@babel/cli": "catalog:",
"@babel/core": "catalog:",
"@babel/preset-react": "catalog:",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

/**
* @file TabPressPointer.interaction.browser.spec.ts
* @input Uses @playwright/test and the interaction harness (a built
* Storybook)
* @output Proves a Tab's hover and press layer under a fine and a coarse
* primary pointer
* @position Interaction proof for packages/core/src/TabList/Tab.tsx
*
* Tab's press layer has a hover branch and a coarse-pointer rule, which
* StyleX makes exclusive. The outcome on every pointer: a fine pointer
* paints the hover, then a pressed overlay that differs from it; under a
* coarse pointer a finger's press paints as an image (`data-astryx-press`),
* and the colour layer stays transparent while the tab is `:active`.
* Chromium only: it holds a finger, and forces `:active`, through the
* DevTools protocol.
*/

import {expect, test, type CDPSession, type Page} from '@playwright/test';
import {openStory, withStorybook} from '@astryxdesign/interaction-harness';

const storybook = withStorybook(test);

const TRANSPARENT = 'rgba(0, 0, 0, 0)';

/** The "Projects" tab: not the story's current one. */
function projectsTab(page: Page) {
return page.locator('button', {hasText: 'Projects'}).first();
}

/** The tab's colour layer (its first aria-hidden span) and the pointer. */
async function tabState(page: Page): Promise<{
colour: string;
image: string;
press: string | null;
coarse: boolean;
hover: boolean;
}> {
return projectsTab(page).evaluate(element => {
const layer = element.querySelector('span[aria-hidden="true"]');
const style = layer == null ? null : getComputedStyle(layer);
return {
colour: style?.backgroundColor ?? '',
image: style?.backgroundImage ?? '',
press: element.getAttribute('data-astryx-press'),
coarse: matchMedia('(pointer: coarse)').matches,
hover: matchMedia('(hover: hover)').matches,
};
});
}

/** The layer's colour once its transition has landed (three equal reads). */
async function settledColour(page: Page): Promise<string> {
let last = '';
let same = 0;
for (let read = 0; read < 60 && same < 3; read++) {
const colour = (await tabState(page)).colour;
same = colour === last ? same + 1 : 1;
last = colour;
await page.waitForTimeout(50);
}
return last;
}

/** The tab's centre, in page coordinates. */
async function tabCentre(page: Page): Promise<{x: number; y: number}> {
const box = await projectsTab(page).boundingBox();
if (box == null) {
throw new Error('the Projects tab has no box');
}
return {x: box.x + box.width / 2, y: box.y + box.height / 2};
}

/** A finger held on the tab's centre; returns the lift. */
async function holdFinger(
page: Page,
cdp: CDPSession,
): Promise<() => Promise<void>> {
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [await tabCentre(page)],
});
return async () => {
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchEnd',
touchPoints: [],
});
};
}

/** Puts the tab in `:active` (or out of it) through the DevTools protocol. */
async function forceActive(cdp: CDPSession, isActive: boolean): Promise<void> {
await cdp.send('DOM.enable');
await cdp.send('CSS.enable');
const {root} = await cdp.send('DOM.getDocument', {depth: -1});
const {nodeIds} = await cdp.send('DOM.querySelectorAll', {
nodeId: root.nodeId,
selector: 'button',
});
for (const nodeId of nodeIds) {
const {outerHTML} = await cdp.send('DOM.getOuterHTML', {nodeId});
if (outerHTML.includes('Projects')) {
await cdp.send('CSS.forcePseudoState', {
nodeId,
forcedPseudoClasses: isActive ? ['active'] : [],
});
return;
}
}
throw new Error('the Projects tab has no node');
}

test.describe('Tab press layer by pointer', () => {
test.skip(
({browserName}) => browserName !== 'chromium',
'Chromium holds a finger and forces :active through the DevTools protocol',
);

test.describe('fine pointer with hover', () => {
test.use({isMobile: false, hasTouch: false});
test('paints the hover, then a pressed overlay that differs from it', async ({
page,
}) => {
await openStory(page, storybook(), 'core-tablist--default');
await projectsTab(page).hover();
const state = await tabState(page);
expect(state.coarse).toBe(false);
expect(state.hover).toBe(true);
await expect
.poll(async () => (await tabState(page)).colour)
.not.toBe(TRANSPARENT);
const hovered = await settledColour(page);
await page.mouse.down();
// The pressed overlay is its own colour, not the hover paint held on.
await expect
.poll(async () => (await tabState(page)).colour)
.not.toBe(hovered);
expect(await settledColour(page)).not.toBe(hovered);
await page.mouse.up();
});
});

test("coarse pointer: a finger's press paints as an image, and the colour layer stays transparent while the tab is :active", async ({
page,
}) => {
await openStory(page, storybook(), 'core-tablist--default');
const cdp = await page.context().newCDPSession(page);
expect((await tabState(page)).coarse).toBe(true);
// The finger's press: the press model marks the tab, and the layer
// paints the pressed overlay as an image.
const lift = await holdFinger(page, cdp);
await expect.poll(async () => (await tabState(page)).press).toBe('on');
expect((await tabState(page)).image).not.toBe('none');
await lift();
await expect.poll(async () => (await tabState(page)).press).not.toBe('on');
// The tab in :active under the coarse pointer: the reset keeps the
// colour layer transparent, past any transition.
await forceActive(cdp, true);
expect(await settledColour(page)).toBe(TRANSPARENT);
await forceActive(cdp, false);
});
});
80 changes: 80 additions & 0 deletions packages/core/src/utils/pressPaintPriority.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

/**
* @file pressPaintPriority.test.ts
* @input Uses vitest, Babel with the StyleX plugin (the repository's shared
* module resolution) and interactionOverlay.stylex.ts
* @output Checks, in the compiled CSS, that the press paint outranks the
* coarse-pointer `:active` reset of the overlay
* @position Regression test for the cascade order of the press overlay
*
* WebKit applies `:active` during a touch. Under a coarse pointer the overlay
* turns the bare `:active` image off, and the press paint
* (`[data-astryx-press="on"]`) must still win: its rule has to sit in a
* higher StyleX priority than the reset. The order is a property of how
* each key is spelled: an imported `defineConsts` key compiles to a higher
* priority than the same literal, which put the reset above the paint and
* erased the touch-down frame.
*/

import fs from 'node:fs';
import {createRequire} from 'node:module';
import path from 'node:path';
import {describe, expect, it} from 'vitest';
import stylexResolution from '../../../../scripts/stylex-module-resolution.cjs';

type Rule = [string, {ltr: string}, number];

// Babel's types are not part of core's typecheck; type the one call used.
const {transformSync} = createRequire(import.meta.url)('@babel/core') as {
transformSync: (
code: string,
options: Record<string, unknown>,
) => {metadata?: unknown} | null;
};

function compiledRules(file: string): Rule[] {
const output = transformSync(fs.readFileSync(file, 'utf8'), {
filename: file,
babelrc: false,
configFile: false,
parserOpts: {plugins: ['typescript', 'jsx']},
plugins: [
[
'@stylexjs/babel-plugin',
{
dev: false,
runtimeInjection: false,
genConditionalClasses: true,
treeshakeCompensation: true,
unstable_moduleResolution: stylexResolution.stylexModuleResolution,
},
],
],
});
return (output?.metadata as {stylex?: Rule[]}).stylex ?? [];
}

describe('press overlay cascade', () => {
it('ranks the press paint above the coarse-pointer :active reset', () => {
const rules = compiledRules(
path.resolve(__dirname, 'interactionOverlay.stylex.ts'),
);
const resets = rules.filter(
// Matched without its condition's text: an imported key compiles to a
// placeholder until the bundle step.
([, {ltr}]) =>
ltr.includes(':active') && ltr.includes('background-image:none'),
);
const paints = rules.filter(
([, {ltr}]) =>
ltr.includes('[data-astryx-press="on"]') &&
ltr.includes('background-image:'),
);
expect(resets.length).toBeGreaterThan(0);
expect(paints.length).toBeGreaterThan(0);
const highestReset = Math.max(...resets.map(([, , priority]) => priority));
const lowestPaint = Math.min(...paints.map(([, , priority]) => priority));
expect(highestReset).toBeLessThan(lowestPaint);
});
});
43 changes: 43 additions & 0 deletions packages/core/src/utils/touchPresentation.stylex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

/**
* @file touchPresentation.stylex.ts
* @input Uses StyleX defineConsts
* @output Exports touchKeys: the touch presentation resolver's queries as
* StyleX at-rule keys
* @position The reference spellings of the touch, compact and iOS-gate
* conditions. Core styles write these as literal keys wherever they need
* them (`spec:AST-094` FR13, `spec:AST-012` FR9), and a test pins each
* literal to these values; utils/touchPresentation.ts holds the same
* queries for script (matchMedia)
*
* These are the spellings to match. A style that changes with these
* conditions writes the same literal key (`'@media (pointer: coarse)'`, the
* iOS gate as that media key outside `'@supports (-webkit-touch-callout:
* none)'`), and a test pins every literal in core to these values. Do not use
Comment on lines +14 to +17

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This says a test pins every literal in core, but only compact-touch keys and the '@media (pointer: coarse)' keys around an iOS gate are compared with the resolver (touchPresentation.test.ts:230–330). A one-character drift in any of the seven plain touch keys in Switch/Switch.tsx, or in COARSE in interactionOverlay.stylex.ts, fails no test. Either add a membership scan, as motion/reducedMotion.test.ts does for the Reduce Motion keys, or narrow this sentence and lines 10–11 to what is pinned.

* an imported key as a computed StyleX key: it compiles to a different
* priority than the literal (6000 against 3200) and reorders the cascade.
* The iOS supports gate is only for a style iOS WebKit itself requires (the
* 16px input floor), never a behaviour.
*
* SYNC: When modified, update touchPresentation.ts and
* touchPresentation.test.ts.
*/

import * as stylex from '@stylexjs/stylex';

export const touchKeys = stylex.defineConsts({
/** The primary pointer is coarse (TOUCH_QUERY). */
touch: '@media (pointer: coarse)',
/** The viewport is compact (COMPACT_QUERY, `spec:AST-012` FR9). */
compact: '@media (width < 768px), (height < 500px)',
/** Both: a presentation such as a sheet in place of a popover. */
compactTouch:
'@media (width < 768px) and (pointer: coarse), (height < 500px) and (pointer: coarse)',
/**
* The iOS WebKit gate, inside `touch` (IOS_SUPPORTS_QUERY): only for a
* style iOS WebKit itself requires (the 16px input floor), never a
* behaviour; touch behaviour is one model on every touch device.
*/
iosSupports: '@supports (-webkit-touch-callout: none)',
});
Loading
Loading