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/gesture-pointer-read-fingers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[fix] A window blur during a pen's press, or during a press by a finger read from its pointer events, now ends it with nothing acting at the later lift, on every surface: a `Switch` no longer toggles at that pen's click. Where fingers come from pointer events, a second finger leaves both to the page's pinch even when the first rests on plain page. A disabled recognizer brings no blocking listener.

@vjeux
7 changes: 4 additions & 3 deletions docs/families/action-groups.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,9 +141,10 @@ Selector's and MultiSelector's options FR1–FR5
is highlighted over a disabled action, a gap, or outside the group
(`spec:AST-094` FR27).
- **FR3 — Sliding off cancels.** A press that lifts outside the group acts on
nothing; a cancelled pointer, a second finger, or the window losing focus
ends the gesture with nothing acting (`spec:AST-094` FR28). Whether a
member that opened for the gesture stays open stays with the member.
nothing; a second finger ends the gesture with nothing acting (`spec:AST-094`
FR28), as a lost focus or a cancelled pointer does (`spec:AST-094` FR44).
Whether a member that opened for the gesture stays open stays with the
member.
- **FR4 — A toggle group toggles only the lifted action.** In a toggle group,
only the action lifted on toggles; sliding across never toggles the actions
passed over.
Expand Down
9 changes: 7 additions & 2 deletions docs/specs/AST-094-touch-presentations/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -414,8 +414,12 @@ under a finger, and a mouse follows `module:DropdownMenu/useMenuPress` FR3 and
finger leaves it (FR11).
- **FR28 — A lift outside keeps the menu open.** A finger that began inside
a menu and lifts outside it MUST act on nothing and leave the menu open. A
cancelled touch, a second finger, or the window losing focus ends the
gesture: nothing acts and the lit row clears.
second finger ends the gesture, as a lost focus or a cancelled touch does
(FR44): nothing acts and the lit row clears.
- **FR44 — A lost focus or a cancel ends every press.** A window blur, or the
browser cancelling the pointer, ends any press the gesture host tracks, on
every surface and for every touch and pen, and nothing acts at the later
lift; a mouse button held through the blur keeps its click.
- **FR29 — DropdownMenu opens on press, with the opening finger's
handoff.** This rule covers DropdownMenu's trigger only; ContextMenu,
sub-menu rows and Selector keep their own opening. Under a finger, a
Expand Down Expand Up @@ -778,6 +782,7 @@ under a finger, and a mouse follows `module:DropdownMenu/useMenuPress` FR3 and
| FR42 | Default-theme geometry tests under a coarse and a fine pointer, and hit-testing between adjacent targets | every component at phone width with a finger and with a mouse; a dense toolbar; a chip and its remove button; a Switch beside a label link | a default-theme box that grows under touch or at phone width other than with its text, or, where no component or module record sets the hit area, a touch target under 44 px where neighbors allow it or a hit area that overlaps a neighbor's target, fails |
| FR43 | Browser tests with a mouse, a trackpad, a pen, a finger, and the keyboard, and resource counts | a pan by drag and by wheel; a slider's thumb under a wheel and a trackpad scroll; a ctrl-wheel pinch; a long press, a right click, and Shift+F10; a mouse press; a handle drag; a row swipe with a mouse and a trackpad; a component with no gesture | a gesture missing under a mouse or pen, a mouse-drag swipe action, a pen pinch, an edge pan under a mouse, a trackpad, or a pen, a press delay under a fine pointer, a wheel or trackpad scroll that changes a thumb's value or does not reach the scroll under it, an idle listener in a component with no gesture, or added stylesheet bytes, fails |
| FR43 trackpad | Unit tests on timed wheel streams; WebKit browser tests with trackpad streams (Chromium's driver wheels read as a mouse wheel) | a form and a required sheet pulled 30 to 240 px by a finger and by a trackpad; mouse lines, notches, ticks and accelerated deltas over a sheet, a pager and a row; ten swipes each starting mid-momentum, ten whose momentum ends, and a slow drag with 150 ms pauses; a trackpad flick and a slow stopped drag on an info sheet; a fling, a slow 60 px swipe, a 42 px spike and back-to-back swipes on a pager; a row opened and swiped back within one gesture, and a long fast swipe; a pinch ending; a trackpad pan through a window blur and through a hidden page | a trackpad stretch that differs from a finger's for the same travel (120 px gives 54 px on the reference sheet), a mouse wheel that moves a sheet, a pager or a row, splitter counts other than ten, ten and one, momentum that moves a surface, a flick released with no velocity, a pager that travels past one page or pages on a spike, an info sheet a flick leaves open or a slow stopped drag closes, an open row that reveals its other side within one gesture, a trackpad pan a window blur or a hidden page leaves mid-gesture, or a pinch that ends after 100 ms fails |
| FR44 | Unit tests of a pen press a window blur ends, on a Switch, a Slider and the host, under a coarse and a fine pointer | a pen press on a Switch and on a Slider, blur mid-press: nothing toggles or commits; a pen whose contact ends in `pointercancel` | with the host keeping no note of a blur-cancelled pen, the Switch toggles at the pen's click |

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 row asks for the Switch and the Slider under a coarse and a fine pointer, but the Switch case runs only under a fine pointer and the Slider case only under a coarse one. Adding the two missing cases, or narrowing the row, keeps the record and the tests in step.

| all | iOS simulator: the UIKit reference capture and the same gesture in Safari; real iOS Safari and Android Chrome devices | per component | an unmatched curve or timing is a parity defect |

## Decision log
Expand Down
80 changes: 80 additions & 0 deletions packages/core/src/Slider/useSliderTouch.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,86 @@ describe('Slider touch presentation (UISlider tracking)', () => {
expect(onChangeEnd).toHaveBeenCalledWith(50);
});

it('commits nothing for a pen press that a window blur cancels, on the thumb or on the track, at its later lift either (spec:AST-094 FR44)', async () => {
stubMatchMedia(true);
stubControlGeometry();
const onChange = vi.fn();
const onChangeEnd = vi.fn();
await renderTouch(
<Controlled initial={50} onChange={onChange} onChangeEnd={onChangeEnd} />,
);
const hit = hitOf(screen.getByRole('slider'));
const control = document.querySelector(
'.astryx-slider-control',
) as HTMLElement;
for (const [target, x] of [
[hit, 150],
[control, 250],
] as const) {
const start = nextStart();
pen('pointerdown', target, x, 12, start);
act(() => {
window.dispatchEvent(new Event('blur'));
});
pen('pointerup', target, x, 12, start + 80);
act(() => {
target.dispatchEvent(
new MouseEvent('click', {bubbles: true, cancelable: true, detail: 1}),
);
});
for (let frame = 0; frame < 40; frame++) {
await act(async () => {
vi.advanceTimersByTime(16);
});
}
}
expect(onChange).not.toHaveBeenCalled();
expect(onChangeEnd).not.toHaveBeenCalled();
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '50');
});

it('commits nothing for a pen press that a window blur cancels under a fine primary pointer too (spec:AST-094 FR44)', async () => {
stubMatchMedia(false);
stubControlGeometry();
const onChange = vi.fn();
const onChangeEnd = vi.fn();
await renderTouch(
<Controlled initial={50} onChange={onChange} onChangeEnd={onChangeEnd} />,
);
const hit = hitOf(screen.getByRole('slider'));
const control = document.querySelector(
'.astryx-slider-control',
) as HTMLElement;
for (const [target, x] of [
[hit, 150],
[control, 250],
] as const) {
const start = nextStart();
pen('pointerdown', target, x, 12, start);
act(() => {
window.dispatchEvent(new Event('blur'));
});
// The microtask after the blur, where an armed press's listeners go.
await act(async () => {
await Promise.resolve();
});
pen('pointerup', target, x, 12, start + 80);
act(() => {
target.dispatchEvent(
new MouseEvent('click', {bubbles: true, cancelable: true, detail: 1}),
);
});
for (let frame = 0; frame < 40; frame++) {
await act(async () => {
vi.advanceTimersByTime(16);
});
}
}
expect(onChange).not.toHaveBeenCalled();
expect(onChangeEnd).not.toHaveBeenCalled();
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '50');
});

it('fires onChangeEnd once, from the end of the touch, never again on a later render', async () => {
stubMatchMedia(true);
stubControlGeometry();
Expand Down
90 changes: 89 additions & 1 deletion packages/core/src/Switch/Switch.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
import {StrictMode} from 'react';
import * as stylex from '@stylexjs/stylex';
import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
import {render, screen, fireEvent, waitFor} from '@testing-library/react';
import {act, render, screen, fireEvent, waitFor} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {Switch} from './Switch';
import {
Expand Down Expand Up @@ -99,6 +99,94 @@ describe('Switch', () => {
expect(screen.getByRole('switch')).toBeInTheDocument();
});

it('toggles nothing for a pen press that a window blur cancels, at its later lift or its click (spec:AST-094 FR44)', async () => {
const onChange = vi.fn();
render(<Switch label="Wi-Fi" value={false} onChange={onChange} />);
const input = screen.getByRole('switch');
const pen = (type: 'pointerdown' | 'pointerup', time: number) => {
const event = new Event(type, {
bubbles: true,
cancelable: true,
composed: true,
});
Object.defineProperties(event, {
pointerId: {value: 7},
pointerType: {value: 'pen'},
isPrimary: {value: true},
clientX: {value: 10},
clientY: {value: 10},
button: {value: 0},
buttons: {value: type === 'pointerup' ? 0 : 1},
timeStamp: {value: time},
});
act(() => {
input.dispatchEvent(event);
});
};
pen('pointerdown', 1000);
act(() => {
window.dispatchEvent(new Event('blur'));
});
// The microtask after the blur, where an armed press's page listeners go.
await act(async () => {
await Promise.resolve();
});
pen('pointerup', 1080);
// The pen's own click: a pointer's, detail 1.
fireEvent.click(input, {detail: 1});
expect(onChange).not.toHaveBeenCalled();
expect(input).not.toBeChecked();
});

it('toggles nothing for a pen press that a window blur cancels under a coarse primary pointer too (spec:AST-094 FR44)', () => {
const original = window.matchMedia;
window.matchMedia = (query: string) => ({
matches: query === '(pointer: coarse)',
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
});
try {
const onChange = vi.fn();
render(<Switch label="Wi-Fi" value={false} onChange={onChange} />);
const input = screen.getByRole('switch');
const pen = (type: 'pointerdown' | 'pointerup', time: number) => {
const event = new Event(type, {
bubbles: true,
cancelable: true,
composed: true,
});
Object.defineProperties(event, {
pointerId: {value: 7},
pointerType: {value: 'pen'},
isPrimary: {value: true},
clientX: {value: 10},
clientY: {value: 10},
button: {value: 0},
buttons: {value: type === 'pointerup' ? 0 : 1},
timeStamp: {value: time},
});
act(() => {
input.dispatchEvent(event);
});
};
pen('pointerdown', 1000);
act(() => {
window.dispatchEvent(new Event('blur'));
});
pen('pointerup', 1080);
fireEvent.click(input, {detail: 1});
expect(onChange).not.toHaveBeenCalled();
expect(input).not.toBeChecked();
} finally {
window.matchMedia = original;
}
});

it('calls onChange with new checked state when clicked', async () => {
const user = userEvent.setup();
const handleChange = vi.fn();
Expand Down
Loading
Loading