Skip to content

fix(menu): iPad scrolling issue #5616

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

Open
wants to merge 9 commits into
base: main
Choose a base branch
from

Conversation

Rajdeepc
Copy link
Contributor

@Rajdeepc Rajdeepc commented Jul 21, 2025

Summary

Fixes an issue where scrolling through menu items in a picker dropdown on iPad would accidentally select the first touched item and close the picker. This was caused by touch events being interpreted as selection events during scroll gestures.

Problem

When users attempted to scroll through menu items in a picker dropdown on iPad, the item they first touched when starting to scroll would be selected instead of allowing the scroll gesture to complete. This made the picker unusable on iPad devices.

Solution

Added touch event handling to distinguish between scroll gestures and selection gestures:

  • Touch tracking: Added properties to track touch start position, time, and scroll state
  • Scroll detection: Implemented logic to detect when a touch gesture is a scroll (vertical movement > threshold within time limit)
  • Selection prevention: Modified handlePointerBasedSelection to prevent selection when scrolling is detected

Technical details

  • Added touchStartY, touchStartTime, isScrolling properties for scroll detection
  • Implemented handleTouchStart, handleTouchMove, handleTouchEnd methods
  • Used passive: true for touch event listeners to improve scrolling performance
  • Added scroll threshold (10px) and time threshold (300ms) for scroll detection
  • Modified handlePointerBasedSelection to check isScrolling state before processing selection

Related issue(s)

Screenshots (if appropriate)

Fix

picker-scrolling.mov

Author's checklist

  • I have read the CONTRIBUTING and PULL_REQUESTS documents.
  • I have reviewed at the Accessibility Practices for this feature, see: Aria Practices
  • I have added automated tests to cover my changes.
  • I have included a well-written changeset if my change needs to be published.
  • I have included updated documentation if my change required it.

Reviewer's checklist

  • Includes a Github Issue with appropriate flag or Jira ticket number without a link
  • Includes thoughtfully written changeset if changes suggested include patch, minor, or major features
  • Automated tests cover all use cases and follow best practices for writing
  • Validated on all supported browsers
  • All VRTs are approved before the author can update Golden Hash

Manual review test cases

Device review

  • Did it pass in Desktop?
  • Did it pass in (emulated) Mobile?
  • Did it pass in (emulated) iPad?

@Rajdeepc Rajdeepc self-assigned this Jul 21, 2025
Copy link

github-actions bot commented Jul 21, 2025

📚 Branch Preview

🔍 Visual Regression Test Results

When a visual regression test fails (or has previously failed while working on this branch), its results can be found in the following URLs:

Deployed to Azure Blob Storage: pr-5616

If the changes are expected, update the current_golden_images_cache hash in the circleci config to accept the new images. Instructions are included in that file.
If the changes are unexpected, you can investigate the cause of the differences and update the code accordingly.

Copy link

Tachometer results

Currently, no packages are changed by this PR...

Copy link

changeset-bot bot commented Jul 21, 2025

🦋 Changeset detected

Latest commit: decc0c7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 84 packages
Name Type
@spectrum-web-components/menu Minor
@spectrum-web-components/breadcrumbs Minor
@spectrum-web-components/combobox Minor
@spectrum-web-components/picker Minor
@spectrum-web-components/custom-vars-viewer Minor
example-project-rollup Patch
example-project-webpack Patch
@spectrum-web-components/story-decorator Minor
@spectrum-web-components/bundle Minor
@spectrum-web-components/action-menu Minor
documentation Patch
@spectrum-web-components/eslint-plugin Minor
@spectrum-web-components/accordion Minor
@spectrum-web-components/action-bar Minor
@spectrum-web-components/action-button Minor
@spectrum-web-components/action-group Minor
@spectrum-web-components/alert-banner Minor
@spectrum-web-components/alert-dialog Minor
@spectrum-web-components/asset Minor
@spectrum-web-components/avatar Minor
@spectrum-web-components/badge Minor
@spectrum-web-components/button-group Minor
@spectrum-web-components/button Minor
@spectrum-web-components/card Minor
@spectrum-web-components/checkbox Minor
@spectrum-web-components/clear-button Minor
@spectrum-web-components/close-button Minor
@spectrum-web-components/coachmark Minor
@spectrum-web-components/color-area Minor
@spectrum-web-components/color-field Minor
@spectrum-web-components/color-handle Minor
@spectrum-web-components/color-loupe Minor
@spectrum-web-components/color-slider Minor
@spectrum-web-components/color-wheel Minor
@spectrum-web-components/contextual-help Minor
@spectrum-web-components/dialog Minor
@spectrum-web-components/divider Minor
@spectrum-web-components/dropzone Minor
@spectrum-web-components/field-group Minor
@spectrum-web-components/field-label Minor
@spectrum-web-components/help-text Minor
@spectrum-web-components/icon Minor
@spectrum-web-components/icons-ui Minor
@spectrum-web-components/icons-workflow Minor
@spectrum-web-components/icons Minor
@spectrum-web-components/iconset Minor
@spectrum-web-components/illustrated-message Minor
@spectrum-web-components/infield-button Minor
@spectrum-web-components/link Minor
@spectrum-web-components/meter Minor
@spectrum-web-components/modal Minor
@spectrum-web-components/number-field Minor
@spectrum-web-components/overlay Minor
@spectrum-web-components/picker-button Minor
@spectrum-web-components/popover Minor
@spectrum-web-components/progress-bar Minor
@spectrum-web-components/progress-circle Minor
@spectrum-web-components/radio Minor
@spectrum-web-components/search Minor
@spectrum-web-components/sidenav Minor
@spectrum-web-components/slider Minor
@spectrum-web-components/split-view Minor
@spectrum-web-components/status-light Minor
@spectrum-web-components/swatch Minor
@spectrum-web-components/switch Minor
@spectrum-web-components/table Minor
@spectrum-web-components/tabs Minor
@spectrum-web-components/tags Minor
@spectrum-web-components/textfield Minor
@spectrum-web-components/thumbnail Minor
@spectrum-web-components/toast Minor
@spectrum-web-components/tooltip Minor
@spectrum-web-components/top-nav Minor
@spectrum-web-components/tray Minor
@spectrum-web-components/underlay Minor
@spectrum-web-components/vrt-compare Minor
@spectrum-web-components/base Minor
@spectrum-web-components/grid Minor
@spectrum-web-components/opacity-checkerboard Minor
@spectrum-web-components/reactive-controllers Minor
@spectrum-web-components/shared Minor
@spectrum-web-components/styles Minor
@spectrum-web-components/theme Minor
@spectrum-web-components/truncated Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@Rajdeepc Rajdeepc marked this pull request as ready for review July 22, 2025 10:00
@Rajdeepc Rajdeepc requested a review from a team as a code owner July 22, 2025 10:00
private touchStartY = 0;
private touchStartTime = 0;
private isScrolling = false;
private scrollThreshold = 10; // pixels
Copy link
Contributor

Choose a reason for hiding this comment

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

Regarding scrollThreshold and scrollTimeThreshold: It's great that this addresses the iPad scrolling issue. Could you provide a bit more context on how the scrollThreshold and scrollTimeThreshold values were determined? Are they based on empirical testing, or are there any common best practices for these values that could be referenced? Just want to ensure they're robust enough to cover various scroll speeds and nuances on iPad.

Copy link
Contributor Author

@Rajdeepc Rajdeepc Jul 24, 2025

Choose a reason for hiding this comment

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

Yes the 10px and 300ms values were determined through empirical testing on various iPad models. One thing we can do is to make these values configurable but I don't think that is something we can do if we get reports of lags or jitter. Tested this bug in iPad Pro, Air, iPad mini in simulator should cover the most of the blast radius of this issue.

Copy link
Contributor

Choose a reason for hiding this comment

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

In the code comments, we need more of an explanation about what this logic does and why it works, so that we avoid future questions on it and regressions. I can't imagine someone else supporting this a year down the road and knowing exactly how to fix it.

this.addEventListener('touchmove', this.handleTouchMove, {
passive: true,
});
this.addEventListener('touchend', this.handleTouchEnd, {
Copy link
Contributor

Choose a reason for hiding this comment

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

there is already a touchend event listener above this. should that be removed in favor of this?

@@ -925,6 +980,14 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) {
}

public override disconnectedCallback(): void {
// Clean up touch event listeners to prevent memory leaks
Copy link
Contributor

@caseyisonit caseyisonit Jul 24, 2025

Choose a reason for hiding this comment

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

why are we removing these and not the others?

Copy link
Contributor

Choose a reason for hiding this comment

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

If we only want these event listeners to exist while attached to the DOM and to remove them during the disconnected phase of the lifecycle, then we should have been adding them on the connected part of the lifecycle instead of the constructor.

Right now, if you were to remove the picker from the DOM and add it back, the listeners would not be re-applied.

await elementUpdated(el);

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const menu = el as any;
Copy link
Contributor

Choose a reason for hiding this comment

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

we should avoid using the any type, I believe this should be Menu base on the signature above

menu.isScrolling = true;

// Try to select an item while scrolling
const clickEvent = new MouseEvent('click', { button: 0 });
Copy link
Contributor

Choose a reason for hiding this comment

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

this is using a mouse event in an anticipated touch experience, I don't this this is the correct check we want.

// Wait for the selection to be processed
await elementUpdated(el);
await elementUpdated(firstItem);
await nextFrame();
Copy link
Contributor

Choose a reason for hiding this comment

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

this shouldnt be needed after an elementUpdated returns. Can you explain why this is needed?


// Store initial value
const initialValue = el.value;
const touchStartEvent = new Event('touchstart', {
Copy link
Contributor

Choose a reason for hiding this comment

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

should we use oneEvent to stub the event so it doesn't introduce side effects?


it('prevents selection when scrolling is detected', async () => {
const el = await fixture<Menu>(html`
<sp-menu selects="single">
Copy link
Contributor

Choose a reason for hiding this comment

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

there arent enough items to simulate scrolling, I'm wondering if these tests should be present in picker versus just a flat menu?

Copy link
Contributor

@caseyisonit caseyisonit left a comment

Choose a reason for hiding this comment

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

For the tests in menu, I'm struggling to understand how its accurately testing scrolling with only two menu items and the menu isn't in a container. These tests feel a little false positive but would like to understand better how they are working.

I've left a number of comments and questions for you. :)

@@ -925,6 +980,14 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) {
}

public override disconnectedCallback(): void {
// Clean up touch event listeners to prevent memory leaks
Copy link
Contributor

Choose a reason for hiding this comment

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

If we only want these event listeners to exist while attached to the DOM and to remove them during the disconnected phase of the lifecycle, then we should have been adding them on the connected part of the lifecycle instead of the constructor.

Right now, if you were to remove the picker from the DOM and add it back, the listeners would not be re-applied.

private touchStartY = 0;
private touchStartTime = 0;
private isScrolling = false;
private scrollThreshold = 10; // pixels
Copy link
Contributor

Choose a reason for hiding this comment

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

In the code comments, we need more of an explanation about what this logic does and why it works, so that we avoid future questions on it and regressions. I can't imagine someone else supporting this a year down the road and knowing exactly how to fix it.

@@ -707,4 +707,249 @@ describe('Menu', () => {
await nextFrame();
expect(el.selected).to.deep.equal(['3']);
});

it('prevents selection when scrolling is detected', async () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Why are these tests on menu when the issue is with Picker's dropdown?

@Rajdeepc
Copy link
Contributor Author

For the tests in menu, I'm struggling to understand how its accurately testing scrolling with only two menu items and the menu isn't in a container. These tests feel a little false positive but would like to understand better how they are working.

I've left a number of comments and questions for you. :)

Good point Casey, those were working since the mouse events are not trying to simulate a touch Event. I checked the repo and also the web-test-runner docs it lacks support for TouchEvent as it doesnt support Node based API execution in headless environment. I refactored the test to test the actual prevention logic instead.

@Rajdeepc Rajdeepc changed the title fix(menu): iPad scrolling issue in picker dropdown fix(menu): iPad scrolling issue Jul 25, 2025
@castastrophe
Copy link
Contributor

@Rajdeepc Is there also a Jira ticket associated with this update?

@caseyisonit
Copy link
Contributor

caseyisonit commented Jul 25, 2025

So i was learning more about the touch events because I haven't used them before and re-read the issue filed. The support request lists Chrome and Safari for the bug and I noticed in the API browser compatibility table touch events aren't supported in safari. I think this solution works for chrome but will need something else for safari.

https://developer.mozilla.org/en-US/docs/Web/API/Touch_events#api.touchevent

@caseyisonit
Copy link
Contributor

caseyisonit commented Jul 25, 2025

i just tried to reproduce the bugs on both chrome and safari on my iPhone with force-popover on from the production URL and I'm able to scroll and select items just fine with no jitter and thrashing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[Bug]: Picker closes on scrolling on a touch device and selects element instead of scrolling
6 participants