Skip to content

Commit b155b2f

Browse files
authored
refactor: RootClickController (#1761)
* Use a single global click handler and active subscriptions to it based on host open state. * Some API naming changes and clean-ups. * Added API documentation.
1 parent 7410dee commit b155b2f

File tree

7 files changed

+166
-67
lines changed

7 files changed

+166
-67
lines changed

src/components/combo/combo.ts

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { ifDefined } from 'lit/directives/if-defined.js';
99
import { live } from 'lit/directives/live.js';
1010

1111
import { themes } from '../../theming/theming-decorator.js';
12-
import { addRootClickHandler } from '../common/controllers/root-click.js';
12+
import { addRootClickController } from '../common/controllers/root-click.js';
1313
import { blazorAdditionalDependencies } from '../common/decorators/blazorAdditionalDependencies.js';
1414
import { blazorIndirectRender } from '../common/decorators/blazorIndirectRender.js';
1515
import { watch } from '../common/decorators/watch.js';
@@ -137,6 +137,18 @@ export default class IgcComboComponent<
137137
return comboValidators;
138138
}
139139

140+
private readonly _rootClickController = addRootClickController(this, {
141+
onHide: async () => {
142+
if (!this.handleClosing()) {
143+
return;
144+
}
145+
this.open = false;
146+
147+
await this.updateComplete;
148+
this.emitEvent('igcClosed');
149+
},
150+
});
151+
140152
protected override readonly _formValue: FormValueOf<ComboValue<T>[]> =
141153
createFormValueState<ComboValue<T>[]>(this, {
142154
initialValue: [],
@@ -461,18 +473,6 @@ export default class IgcComboComponent<
461473
this._rootClickController.update();
462474
}
463475

464-
private _rootClickController = addRootClickHandler(this, {
465-
hideCallback: async () => {
466-
if (!this.handleClosing()) {
467-
return;
468-
}
469-
this.open = false;
470-
471-
await this.updateComplete;
472-
this.emitEvent('igcClosed');
473-
},
474-
});
475-
476476
constructor() {
477477
super();
478478

Lines changed: 118 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,85 +1,161 @@
11
import type { ReactiveController, ReactiveControllerHost } from 'lit';
2-
import { findElementFromEventPath } from '../util.js';
2+
import { findElementFromEventPath, isEmpty } from '../util.js';
33

4+
/** Configuration options for the RootClickController */
45
type RootClickControllerConfig = {
5-
hideCallback?: () => void;
6+
/**
7+
* An optional callback function to execute when an outside click occurs.
8+
* If not provided, the `hide()` method of the host will be called.
9+
*/
10+
onHide?: () => void;
11+
/**
12+
* An optional additional HTMLElement that, if clicked, should not trigger the hide action.
13+
* This is useful for elements like a toggle button that opens the component.
14+
*/
615
target?: HTMLElement;
716
};
817

18+
/** Interface for the host element that the RootClickController will be attached to. */
919
interface RootClickControllerHost extends ReactiveControllerHost, HTMLElement {
20+
/**
21+
* Indicates whether the host element is currently open or visible.
22+
*/
1023
open: boolean;
24+
/**
25+
* If true, outside clicks will not trigger the hide action.
26+
*/
1127
keepOpenOnOutsideClick?: boolean;
28+
/**
29+
* A method on the host to hide or close itself.
30+
* This will be called if `hideCallback` is not provided in the config.
31+
*/
1232
hide(): void;
1333
}
1434

35+
let rootClickListenerActive = false;
36+
37+
const HostConfigs = new WeakMap<
38+
RootClickControllerHost,
39+
RootClickControllerConfig
40+
>();
41+
42+
const ActiveHosts = new Set<RootClickControllerHost>();
43+
44+
function handleRootClick(event: MouseEvent): void {
45+
for (const host of ActiveHosts) {
46+
const config = HostConfigs.get(host);
47+
48+
if (host.keepOpenOnOutsideClick) {
49+
continue;
50+
}
51+
52+
const targets: Set<Element> = config?.target
53+
? new Set([host, config.target])
54+
: new Set([host]);
55+
56+
if (!findElementFromEventPath((node) => targets.has(node), event)) {
57+
config?.onHide ? config.onHide.call(host) : host.hide();
58+
}
59+
}
60+
}
61+
1562
/* blazorSuppress */
16-
export class RootClickController implements ReactiveController {
63+
/**
64+
* A Lit ReactiveController that manages global click listeners to hide a component
65+
* when a click occurs outside of the component or its specified target.
66+
*
67+
* This controller implements a singleton pattern for the document click listener,
68+
* meaning only one event listener is attached to `document` regardless of how many
69+
* instances of `RootClickController` are active. Each controller instance
70+
* subscribes to this single listener.
71+
*/
72+
class RootClickController implements ReactiveController {
73+
private readonly _host: RootClickControllerHost;
74+
private _config?: RootClickControllerConfig;
75+
1776
constructor(
18-
private readonly host: RootClickControllerHost,
19-
private config?: RootClickControllerConfig
77+
host: RootClickControllerHost,
78+
config?: RootClickControllerConfig
2079
) {
21-
this.host.addController(this);
22-
}
80+
this._host = host;
81+
this._config = config;
82+
this._host.addController(this);
2383

24-
private addEventListeners() {
25-
if (!this.host.keepOpenOnOutsideClick) {
26-
document.addEventListener('click', this, { capture: true });
84+
if (this._config) {
85+
HostConfigs.set(this._host, this._config);
2786
}
2887
}
2988

30-
private removeEventListeners() {
31-
document.removeEventListener('click', this, { capture: true });
32-
}
33-
34-
private configureListeners() {
35-
this.host.open ? this.addEventListeners() : this.removeEventListeners();
36-
}
89+
/**
90+
* Adds the host to the set of active hosts and ensures the global
91+
* document click listener is active if needed.
92+
*/
93+
private _addActiveHost(): void {
94+
ActiveHosts.add(this._host);
3795

38-
private shouldHide(event: PointerEvent) {
39-
const targets = new Set<Element>([this.host]);
96+
if (this._config) {
97+
HostConfigs.set(this._host, this._config);
4098

41-
if (this.config?.target) {
42-
targets.add(this.config.target);
99+
if (!rootClickListenerActive) {
100+
document.addEventListener('click', handleRootClick, { capture: true });
101+
rootClickListenerActive = true;
102+
}
43103
}
44-
45-
return !findElementFromEventPath((node) => targets.has(node), event);
46104
}
47105

48-
public handleEvent(event: PointerEvent) {
49-
if (this.host.keepOpenOnOutsideClick) {
50-
return;
51-
}
106+
/**
107+
* Removes the host from the set of active hosts and removes the global
108+
* document click listener if no other hosts are active.
109+
*/
110+
private _removeActiveHost(): void {
111+
ActiveHosts.delete(this._host);
52112

53-
if (this.shouldHide(event)) {
54-
this.hide();
113+
if (isEmpty(ActiveHosts) && rootClickListenerActive) {
114+
document.removeEventListener('click', handleRootClick, { capture: true });
115+
rootClickListenerActive = false;
55116
}
56117
}
57118

58-
private hide() {
59-
this.config?.hideCallback
60-
? this.config.hideCallback.call(this.host)
61-
: this.host.hide();
119+
/**
120+
* Configures the active state of the controller based on the host's `open` property.
121+
* If `host.open` is true, the controller becomes active; otherwise, it becomes inactive.
122+
*/
123+
private _configureListeners(): void {
124+
this._host.open && !this._host.keepOpenOnOutsideClick
125+
? this._addActiveHost()
126+
: this._removeActiveHost();
62127
}
63128

64-
public update(config?: RootClickControllerConfig) {
129+
/** Updates the controller configuration and active state. */
130+
public update(config?: RootClickControllerConfig): void {
65131
if (config) {
66-
this.config = { ...this.config, ...config };
132+
this._config = { ...this._config, ...config };
133+
HostConfigs.set(this._host, this._config);
67134
}
68-
this.configureListeners();
135+
136+
this._configureListeners();
69137
}
70138

71-
public hostConnected() {
72-
this.configureListeners();
139+
/** @internal */
140+
public hostConnected(): void {
141+
this._configureListeners();
73142
}
74143

75-
public hostDisconnected() {
76-
this.removeEventListeners();
144+
/** @internal */
145+
public hostDisconnected(): void {
146+
this._removeActiveHost();
77147
}
78148
}
79149

80-
export function addRootClickHandler(
150+
/**
151+
* Creates and adds a {@link RootClickController} instance with a {@link RootClickControllerConfig | configuration}
152+
* to the given {@link RootClickControllerHost | host}.
153+
*/
154+
export function addRootClickController(
81155
host: RootClickControllerHost,
82156
config?: RootClickControllerConfig
83-
) {
157+
): RootClickController {
84158
return new RootClickController(host, config);
85159
}
160+
161+
export type { RootClickController };

src/components/common/mixins/combo-box.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { LitElement } from 'lit';
22
import { property } from 'lit/decorators.js';
3-
4-
import { addRootClickHandler } from '../controllers/root-click.js';
3+
import type { RootClickController } from '../controllers/root-click.js';
54
import { iterNodes } from '../util.js';
65
import type { UnpackCustomEvent } from './event-emitter.js';
76

@@ -22,7 +21,7 @@ export abstract class IgcBaseComboBoxLikeComponent extends LitElement {
2221
eventInitDict?: CustomEventInit<D>
2322
) => boolean;
2423

25-
protected _rootClickController = addRootClickHandler(this);
24+
protected abstract _rootClickController: RootClickController;
2625

2726
/**
2827
* Whether the component dropdown should be kept open on selection.

src/components/date-picker/date-picker.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
arrowUp,
2020
escapeKey,
2121
} from '../common/controllers/key-bindings.js';
22+
import { addRootClickController } from '../common/controllers/root-click.js';
2223
import { blazorAdditionalDependencies } from '../common/decorators/blazorAdditionalDependencies.js';
2324
import { shadowOptions } from '../common/decorators/shadow-options.js';
2425
import { watch } from '../common/decorators/watch.js';
@@ -198,6 +199,13 @@ export default class IgcDatePickerComponent extends FormAssociatedRequiredMixin(
198199
transformers: defaultDateTimeTransformers,
199200
});
200201

202+
protected override readonly _rootClickController = addRootClickController(
203+
this,
204+
{
205+
onHide: this._handleClosing,
206+
}
207+
);
208+
201209
@query(IgcDateTimeInputComponent.tagName)
202210
private readonly _input!: IgcDateTimeInputComponent;
203211

@@ -462,8 +470,6 @@ export default class IgcDatePickerComponent extends FormAssociatedRequiredMixin(
462470
addSafeEventListener(this, 'focusin', this._handleFocusIn);
463471
addSafeEventListener(this, 'focusout', this._handleFocusOut);
464472

465-
this._rootClickController.update({ hideCallback: this._handleClosing });
466-
467473
addKeybindings(this, {
468474
skip: () => this.disabled || this.readOnly,
469475
bindingDefaults: { preventDefault: true },

src/components/date-range-picker/date-range-picker.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
arrowUp,
2525
escapeKey,
2626
} from '../common/controllers/key-bindings.js';
27+
import { addRootClickController } from '../common/controllers/root-click.js';
2728
import { blazorAdditionalDependencies } from '../common/decorators/blazorAdditionalDependencies.js';
2829
import { shadowOptions } from '../common/decorators/shadow-options.js';
2930
import { watch } from '../common/decorators/watch.js';
@@ -220,6 +221,13 @@ export default class IgcDateRangePickerComponent extends FormAssociatedRequiredM
220221
transformers: defaultDateRangeTransformers,
221222
});
222223

224+
protected override readonly _rootClickController = addRootClickController(
225+
this,
226+
{
227+
onHide: this._handleClosing,
228+
}
229+
);
230+
223231
private _activeDate: Date | null = null;
224232
private _min: Date | null = null;
225233
private _max: Date | null = null;
@@ -582,8 +590,6 @@ export default class IgcDateRangePickerComponent extends FormAssociatedRequiredM
582590
addSafeEventListener(this, 'focusin', this._handleFocusIn);
583591
addSafeEventListener(this, 'focusout', this._handleFocusOut);
584592

585-
this._rootClickController.update({ hideCallback: this._handleClosing });
586-
587593
addKeybindings(this, {
588594
skip: () => this.disabled || this.readOnly,
589595
bindingDefaults: { preventDefault: true },

src/components/dropdown/dropdown.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
type KeyBindingObserverCleanup,
1717
tabKey,
1818
} from '../common/controllers/key-bindings.js';
19+
import { addRootClickController } from '../common/controllers/root-click.js';
1920
import { addRootScrollHandler } from '../common/controllers/root-scroll.js';
2021
import { blazorAdditionalDependencies } from '../common/decorators/blazorAdditionalDependencies.js';
2122
import { watch } from '../common/decorators/watch.js';
@@ -93,12 +94,19 @@ export default class IgcDropdownComponent extends EventEmitterMixin<
9394
);
9495
}
9596

96-
private _keyBindings: KeyBindingController;
97+
private readonly _keyBindings: KeyBindingController;
9798

9899
private _rootScrollController = addRootScrollHandler(this, {
99100
hideCallback: this.handleClosing,
100101
});
101102

103+
protected override readonly _rootClickController = addRootClickController(
104+
this,
105+
{
106+
onHide: this.handleClosing,
107+
}
108+
);
109+
102110
@state()
103111
protected _selectedItem: IgcDropdownItemComponent | null = null;
104112

@@ -201,8 +209,6 @@ export default class IgcDropdownComponent extends EventEmitterMixin<
201209
constructor() {
202210
super();
203211

204-
this._rootClickController.update({ hideCallback: this.handleClosing });
205-
206212
this._keyBindings = addKeybindings(this, {
207213
skip: () => !this.open,
208214
bindingDefaults: { preventDefault: true, triggers: ['keydownRepeat'] },

src/components/select/select.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
spaceBar,
2323
tabKey,
2424
} from '../common/controllers/key-bindings.js';
25+
import { addRootClickController } from '../common/controllers/root-click.js';
2526
import { addRootScrollHandler } from '../common/controllers/root-scroll.js';
2627
import { blazorAdditionalDependencies } from '../common/decorators/blazorAdditionalDependencies.js';
2728
import { watch } from '../common/decorators/watch.js';
@@ -133,6 +134,13 @@ export default class IgcSelectComponent extends FormAssociatedRequiredMixin(
133134
);
134135
}
135136

137+
protected override readonly _rootClickController = addRootClickController(
138+
this,
139+
{
140+
onHide: this.handleClosing,
141+
}
142+
);
143+
136144
protected override readonly _formValue: FormValueOf<string | undefined> =
137145
createFormValueState<string | undefined>(this, {
138146
initialValue: undefined,
@@ -277,8 +285,6 @@ export default class IgcSelectComponent extends FormAssociatedRequiredMixin(
277285
constructor() {
278286
super();
279287

280-
this._rootClickController.update({ hideCallback: this.handleClosing });
281-
282288
addKeybindings(this, {
283289
skip: () => this.disabled,
284290
bindingDefaults: { preventDefault: true, triggers: ['keydownRepeat'] },

0 commit comments

Comments
 (0)