Repository navigation
test(core): one internal definition pins core's touch style keys #8331
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+442
−4
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
163 changes: 163 additions & 0 deletions
163
packages/core/src/TabList/__tests__/TabPressPointer.interaction.browser.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| * 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)', | ||
| }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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 inSwitch/Switch.tsx, or inCOARSEininteractionOverlay.stylex.ts, fails no test. Either add a membership scan, asmotion/reducedMotion.test.tsdoes for the Reduce Motion keys, or narrow this sentence and lines 10–11 to what is pinned.