Skip to content

Commit fe87d32

Browse files
authored
fix(root-click-controller): Keep track of pointerdown origin (#1988)
Closes #1987
1 parent fe886d9 commit fe87d32

File tree

2 files changed

+62
-16
lines changed

2 files changed

+62
-16
lines changed

src/components/combo/combo.spec.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
isFocused,
2020
simulateClick,
2121
simulateKeyboard,
22+
simulatePointerDown,
2223
} from '../common/utils.spec.js';
2324
import {
2425
runValidationContainerTests,
@@ -1401,6 +1402,22 @@ describe('Combo', () => {
14011402

14021403
expect(items(combo).length).to.equal(cities.length);
14031404
});
1405+
1406+
it('issue 1987 - do not close the dropdown on user pointer selection', async () => {
1407+
await combo.show();
1408+
await list.layoutComplete;
1409+
1410+
// Trigger a pointerdown event inside the list element
1411+
simulatePointerDown(list);
1412+
await elementUpdated(combo);
1413+
1414+
// Then a click outside the list element (for example user selection with a pointer device)
1415+
simulateClick(document.body);
1416+
await elementUpdated(combo);
1417+
1418+
// The dropdown should remain open
1419+
expect(combo.open).to.be.true;
1420+
});
14041421
});
14051422

14061423
describe('Form integration', () => {

src/components/common/controllers/root-click.ts

Lines changed: 45 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { ReactiveController, ReactiveControllerHost } from 'lit';
2+
import { createAbortHandle } from '../abort-handler.js';
23
import { findElementFromEventPath, isEmpty } from '../util.js';
34

45
/** Configuration options for the RootClickController */
@@ -33,25 +34,49 @@ interface RootClickControllerHost extends ReactiveControllerHost, HTMLElement {
3334
}
3435

3536
let rootClickListenerActive = false;
37+
let pointerDownInsideHost = false;
3638

37-
const HostConfigs = new WeakMap<
39+
const HOST_CONFIGURATIONS = new WeakMap<
3840
RootClickControllerHost,
3941
RootClickControllerConfig
4042
>();
4143

42-
const ActiveHosts = new Set<RootClickControllerHost>();
44+
const ACTIVE_HOSTS = new Set<RootClickControllerHost>();
4345

44-
function handleRootClick(event: MouseEvent): void {
45-
for (const host of ActiveHosts) {
46-
const config = HostConfigs.get(host);
46+
function handlePointerDown(event: PointerEvent): void {
47+
// Check if the pointerdown occurred inside any active host or its target
48+
for (const host of ACTIVE_HOSTS) {
49+
const config = HOST_CONFIGURATIONS.get(host);
50+
const targets: Set<Element> = new Set(
51+
config?.target ? [host, config.target] : [host]
52+
);
53+
54+
if (findElementFromEventPath((node) => targets.has(node), event)) {
55+
pointerDownInsideHost = true;
56+
return;
57+
}
58+
}
59+
60+
pointerDownInsideHost = false;
61+
}
62+
63+
function handleRootClick(event: PointerEvent): void {
64+
// If the interaction started inside a host, don't trigger hide
65+
if (pointerDownInsideHost) {
66+
pointerDownInsideHost = false;
67+
return;
68+
}
69+
70+
for (const host of ACTIVE_HOSTS) {
71+
const config = HOST_CONFIGURATIONS.get(host);
4772

4873
if (host.keepOpenOnOutsideClick) {
4974
continue;
5075
}
5176

52-
const targets: Set<Element> = config?.target
53-
? new Set([host, config.target])
54-
: new Set([host]);
77+
const targets: Set<Element> = new Set(
78+
config?.target ? [host, config.target] : [host]
79+
);
5580

5681
if (!findElementFromEventPath((node) => targets.has(node), event)) {
5782
config?.onHide ? config.onHide.call(host) : host.hide();
@@ -71,6 +96,7 @@ function handleRootClick(event: MouseEvent): void {
7196
*/
7297
class RootClickController implements ReactiveController {
7398
private readonly _host: RootClickControllerHost;
99+
private readonly _abortHandler = createAbortHandle();
74100
private _config?: RootClickControllerConfig;
75101

76102
constructor(
@@ -82,7 +108,7 @@ class RootClickController implements ReactiveController {
82108
this._host.addController(this);
83109

84110
if (this._config) {
85-
HostConfigs.set(this._host, this._config);
111+
HOST_CONFIGURATIONS.set(this._host, this._config);
86112
}
87113
}
88114

@@ -91,13 +117,16 @@ class RootClickController implements ReactiveController {
91117
* document click listener is active if needed.
92118
*/
93119
private _addActiveHost(): void {
94-
ActiveHosts.add(this._host);
120+
ACTIVE_HOSTS.add(this._host);
95121

96122
if (this._config) {
97-
HostConfigs.set(this._host, this._config);
123+
HOST_CONFIGURATIONS.set(this._host, this._config);
98124

99125
if (!rootClickListenerActive) {
100-
document.addEventListener('click', handleRootClick, { capture: true });
126+
const options = { capture: true, signal: this._abortHandler.signal };
127+
128+
document.addEventListener('pointerdown', handlePointerDown, options);
129+
document.addEventListener('click', handleRootClick, options);
101130
rootClickListenerActive = true;
102131
}
103132
}
@@ -108,10 +137,10 @@ class RootClickController implements ReactiveController {
108137
* document click listener if no other hosts are active.
109138
*/
110139
private _removeActiveHost(): void {
111-
ActiveHosts.delete(this._host);
140+
ACTIVE_HOSTS.delete(this._host);
112141

113-
if (isEmpty(ActiveHosts) && rootClickListenerActive) {
114-
document.removeEventListener('click', handleRootClick, { capture: true });
142+
if (isEmpty(ACTIVE_HOSTS) && rootClickListenerActive) {
143+
this._abortHandler.abort();
115144
rootClickListenerActive = false;
116145
}
117146
}
@@ -130,7 +159,7 @@ class RootClickController implements ReactiveController {
130159
public update(config?: RootClickControllerConfig): void {
131160
if (config) {
132161
this._config = { ...this._config, ...config };
133-
HostConfigs.set(this._host, this._config);
162+
HOST_CONFIGURATIONS.set(this._host, this._config);
134163
}
135164

136165
this._configureListeners();

0 commit comments

Comments
 (0)