Skip to content

Commit ae61361

Browse files
iuliautaRajdeep Chandra
andauthored
fix(picker): dispatch click events for menu items on touch devices (#5901)
* fix(menu): ipad menu item click * test: menu now handle clicks properly * docs: apply changeset formatting suggestions from review * refactor: address PR review feedback - Remove reactive-controllers changeset (not needed) - Simplify picker changeset description - Remove duplicate bindEvents() calls in tests - Query menu items through optionsMenu to avoid shadow DOM issues - Reset both isTouchDevice and isMobile in afterEach * fix: use childItems.find() to access menu item in test - Changed from querySelector to childItems.find() as menu items are tracked in the childItems array - Changed assertion from to.not.be.null to to.not.be.undefined (find() returns undefined) * fix: use direct index to access menu item in test - Changed from find() to direct array access childItems[1] - Simpler and more direct approach as suggested by reviewer --------- Co-authored-by: Rajdeep Chandra <[email protected]>
1 parent 1ec8880 commit ae61361

File tree

5 files changed

+150
-9
lines changed

5 files changed

+150
-9
lines changed

.changeset/brave-deer-pick.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@spectrum-web-components/picker': patch
3+
---
4+
5+
**Fixed**: click events are now dispatched from menu-items on touch devices
6+
7+
- All touch devices (including iPads with screen widths >743px) now correctly use click events instead of drag-and-select behavior

1st-gen/packages/picker/src/InteractionController.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -179,15 +179,6 @@ export class InteractionController implements ReactiveController {
179179

180180
hostConnected(): void {
181181
this.init();
182-
this.host.addEventListener('sp-opened', () => {
183-
/**
184-
* set shouldSupportDragAndSelect to false for mobile
185-
* to prevent click event being captured behind the menu-tray
186-
* we do this here because the menu gets reinitialized on overlay open
187-
*/
188-
this.host.optionsMenu.shouldSupportDragAndSelect =
189-
!this.host.isMobile.matches;
190-
});
191182
this.host.addEventListener('sp-closed', () => {
192183
if (
193184
!this.open &&

1st-gen/packages/picker/src/Picker.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import type { SlottableRequestEvent } from '@spectrum-web-components/overlay/src
4949
import { DependencyManagerController } from '@spectrum-web-components/reactive-controllers/src/DependencyManger.js';
5050
import {
5151
IS_MOBILE,
52+
IS_TOUCH_DEVICE,
5253
MatchMediaController,
5354
} from '@spectrum-web-components/reactive-controllers/src/MatchMedia.js';
5455
import type { Tooltip } from '@spectrum-web-components/tooltip';
@@ -85,6 +86,8 @@ export class PickerBase extends SizedMixin(SpectrumElement, {
8586

8687
public isMobile = new MatchMediaController(this, IS_MOBILE);
8788

89+
public isTouchDevice = new MatchMediaController(this, IS_TOUCH_DEVICE);
90+
8891
public strategy!: DesktopController | MobileController;
8992

9093
@state()
@@ -774,6 +777,7 @@ export class PickerBase extends SizedMixin(SpectrumElement, {
774777
role=${this.listRole}
775778
.selects=${this.selects}
776779
.selected=${this.value ? [this.value] : []}
780+
.shouldSupportDragAndSelect=${!this.isTouchDevice.matches}
777781
size=${this.size}
778782
@sp-menu-item-keydown=${this.handleEscape}
779783
@sp-menu-item-added-or-updated=${this.shouldManageSelection}

1st-gen/packages/picker/test/picker-responsive.test.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,19 @@ import {
1515
expect,
1616
fixture,
1717
html,
18+
oneEvent,
1819
waitUntil,
1920
} from '@open-wc/testing';
2021
import '@spectrum-web-components/field-label/sp-field-label.js';
2122
import '@spectrum-web-components/menu/sp-menu-divider.js';
23+
import type { MenuItem } from '@spectrum-web-components/menu';
2224
import '@spectrum-web-components/menu/sp-menu-item.js';
2325
import { Picker } from '@spectrum-web-components/picker';
2426
import '@spectrum-web-components/picker/sync/sp-picker.js';
2527
import { spreadProps } from '../../../test/lit-helpers.js';
2628
import { Popover } from '@spectrum-web-components/popover';
2729
import { Tray } from '@spectrum-web-components/tray/src/Tray.js';
30+
import { spy } from 'sinon';
2831

2932
describe('Picker, responsive', () => {
3033
let el: Picker;
@@ -172,4 +175,139 @@ describe('Picker, responsive', () => {
172175
expect(popover, 'popover').to.not.be.null;
173176
});
174177
});
178+
179+
describe('touch device detection', () => {
180+
afterEach(() => {
181+
// Reset touch device and mobile simulation.
182+
if (el && el.isTouchDevice) {
183+
el.isTouchDevice.matches = false;
184+
}
185+
if (el && el.isMobile) {
186+
el.isMobile.matches = false;
187+
}
188+
});
189+
190+
it('sets shouldSupportDragAndSelect to false on touch devices', async () => {
191+
el = await pickerFixture();
192+
await elementUpdated(el);
193+
/**
194+
* This is a hack to set the `isTouchDevice` property to true
195+
* so that we can test the touch device behavior.
196+
*/
197+
el.isTouchDevice.matches = true;
198+
await elementUpdated(el);
199+
200+
// Open the picker to initialize the menu.
201+
const opened = oneEvent(el, 'sp-opened');
202+
el.open = true;
203+
await opened;
204+
205+
// Wait for menu to be ready.
206+
await waitUntil(
207+
() => el.optionsMenu && el.optionsMenu.childItems.length > 0,
208+
'Menu should be initialized'
209+
);
210+
211+
// Check that shouldSupportDragAndSelect is false.
212+
expect(el.optionsMenu.shouldSupportDragAndSelect).to.be.false;
213+
});
214+
215+
it('sets shouldSupportDragAndSelect to true on desktop devices', async () => {
216+
el = await pickerFixture();
217+
await elementUpdated(el);
218+
219+
// Ensure we're not on a touch device.
220+
el.isTouchDevice.matches = false;
221+
await elementUpdated(el);
222+
223+
// Open the picker to initialize the menu.
224+
const opened = oneEvent(el, 'sp-opened');
225+
el.open = true;
226+
await opened;
227+
228+
// Wait for menu to be ready.
229+
await waitUntil(
230+
() => el.optionsMenu && el.optionsMenu.childItems.length > 0,
231+
'Menu should be initialized'
232+
);
233+
234+
// Check that shouldSupportDragAndSelect is true.
235+
expect(el.optionsMenu.shouldSupportDragAndSelect).to.be.true;
236+
});
237+
238+
it('dispatches change event when menu item is clicked on touch device', async () => {
239+
el = await pickerFixture();
240+
await elementUpdated(el);
241+
242+
const changeSpy = spy();
243+
el.addEventListener('change', changeSpy);
244+
245+
/**
246+
* This is a hack to set the `isTouchDevice` property to true
247+
* so that we can test the iPad/tablet behavior.
248+
*/
249+
el.isTouchDevice.matches = true;
250+
await elementUpdated(el);
251+
252+
// Open the picker.
253+
const opened = oneEvent(el, 'sp-opened');
254+
el.open = true;
255+
await opened;
256+
await elementUpdated(el);
257+
258+
// Wait for menu to be ready.
259+
await waitUntil(
260+
() => el.optionsMenu && el.optionsMenu.childItems.length > 0,
261+
'Menu should be initialized'
262+
);
263+
264+
// Wait for menu to be fully updated.
265+
await el.optionsMenu.updateComplete;
266+
await elementUpdated(el.optionsMenu);
267+
268+
// Verify shouldSupportDragAndSelect is false on touch devices.
269+
expect(el.optionsMenu.shouldSupportDragAndSelect).to.be.false;
270+
271+
// Get the second menu item (value="option-2") from childItems.
272+
const menuItem = el.optionsMenu.childItems[1] as MenuItem;
273+
expect(menuItem).to.not.be.null;
274+
await elementUpdated(menuItem);
275+
276+
// Ensure menu is not in scrolling state (which would prevent selection).
277+
el.optionsMenu.isScrolling = false;
278+
279+
// Click the menu item.
280+
const closed = oneEvent(el, 'sp-closed');
281+
menuItem.click();
282+
await closed;
283+
284+
// Verify the change event was dispatched.
285+
expect(changeSpy.callCount).to.equal(1);
286+
expect(el.value).to.equal('option-2');
287+
});
288+
289+
it('uses isTouchDevice instead of isMobile for shouldSupportDragAndSelect', async () => {
290+
el = await pickerFixture();
291+
await elementUpdated(el);
292+
293+
// Simulate iPad: isMobile is false but isTouchDevice is true.
294+
el.isMobile.matches = false;
295+
el.isTouchDevice.matches = true;
296+
await elementUpdated(el);
297+
298+
// Open the picker.
299+
const opened = oneEvent(el, 'sp-opened');
300+
el.open = true;
301+
await opened;
302+
303+
// Wait for menu to be ready.
304+
await waitUntil(
305+
() => el.optionsMenu && el.optionsMenu.childItems.length > 0,
306+
'Menu should be initialized'
307+
);
308+
309+
// The fix: shouldSupportDragAndSelect should be false based on isTouchDevice.
310+
expect(el.optionsMenu.shouldSupportDragAndSelect).to.be.false;
311+
});
312+
});
175313
});

1st-gen/tools/reactive-controllers/src/MatchMedia.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type { ReactiveController, ReactiveElement } from 'lit';
1414
export const DARK_MODE = '(prefers-color-scheme: dark)';
1515
export const IS_MOBILE =
1616
'(max-width: 743px) and (hover: none) and (pointer: coarse)';
17+
export const IS_TOUCH_DEVICE = '(hover: none) and (pointer: coarse)';
1718

1819
export class MatchMediaController implements ReactiveController {
1920
key = Symbol('match-media-key');

0 commit comments

Comments
 (0)