Skip to content

Commit 3d166ea

Browse files
committed
refactor: Tooltip controller
Code restructure and internal event listeners state are now handled with AbortController
1 parent c2ffb2e commit 3d166ea

File tree

3 files changed

+134
-132
lines changed

3 files changed

+134
-132
lines changed
Lines changed: 117 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,28 @@
11
import type { ReactiveController } from 'lit';
2-
import { getElementByIdFromRoot } from '../common/util.js';
2+
import { getElementByIdFromRoot, isString } from '../common/util.js';
33
import service from './tooltip-service.js';
44
import type IgcTooltipComponent from './tooltip.js';
55

6-
type TooltipAnchor = Element | null | undefined;
7-
8-
type TooltipCallbacks = {
9-
onShow: (event?: Event) => unknown;
10-
onHide: (event?: Event) => unknown;
11-
onEscape: (event?: Event) => unknown;
12-
};
13-
146
class TooltipController implements ReactiveController {
7+
//#region Internal properties and state
8+
159
private readonly _host: IgcTooltipComponent;
16-
private _anchor: TooltipAnchor;
17-
private _isTransientAnchor = false;
10+
private readonly _options: TooltipCallbacks;
11+
12+
private _hostAbortController: AbortController | null = null;
13+
private _anchorAbortController: AbortController | null = null;
1814

19-
private _options: TooltipCallbacks;
2015
private _showTriggers = new Set(['pointerenter']);
2116
private _hideTriggers = new Set(['pointerleave', 'click']);
2217

18+
private _anchor: TooltipAnchor;
19+
private _isTransientAnchor = false;
2320
private _open = false;
2421

22+
//#endregion
23+
24+
//#region Public properties
25+
2526
/** Whether the tooltip is in shown state. */
2627
public get open(): boolean {
2728
return this._open;
@@ -52,26 +53,6 @@ class TooltipController implements ReactiveController {
5253
return this._anchor;
5354
}
5455

55-
/**
56-
* Removes all triggers from the previous `anchor` target and rebinds the current
57-
* sets back to the new value if it exists.
58-
*/
59-
public setAnchor(value: TooltipAnchor, transient = false): void {
60-
if (this._anchor === value) return;
61-
62-
this._dispose();
63-
this._anchor = value;
64-
this._isTransientAnchor = transient;
65-
66-
for (const each of this._showTriggers) {
67-
this._anchor?.addEventListener(each, this);
68-
}
69-
70-
for (const each of this._hideTriggers) {
71-
this._anchor?.addEventListener(each, this);
72-
}
73-
}
74-
7556
/**
7657
* Returns the current set of hide triggers as a comma-separated string.
7758
*/
@@ -87,13 +68,9 @@ class TooltipController implements ReactiveController {
8768
* set of triggers from it and rebind it with the new one.
8869
*/
8970
public set hideTriggers(value: string) {
90-
const triggers = parseTriggers(value);
91-
92-
if (this._anchor) {
93-
this._toggleTriggers(this._hideTriggers, triggers);
94-
}
95-
96-
this._hideTriggers = triggers;
71+
this._hideTriggers = parseTriggers(value);
72+
this._removeAnchorListeners();
73+
this._addAnchorListeners();
9774
}
9875

9976
/**
@@ -111,66 +88,128 @@ class TooltipController implements ReactiveController {
11188
* set of triggers from it and rebind it with the new one.
11289
*/
11390
public set showTriggers(value: string) {
114-
const triggers = parseTriggers(value);
115-
116-
if (this._anchor) {
117-
this._toggleTriggers(this._showTriggers, triggers);
118-
}
119-
120-
this._showTriggers = triggers;
91+
this._showTriggers = parseTriggers(value);
92+
this._removeAnchorListeners();
93+
this._addAnchorListeners();
12194
}
12295

96+
//#endregion
97+
12398
constructor(tooltip: IgcTooltipComponent, options: TooltipCallbacks) {
12499
this._host = tooltip;
125100
this._options = options;
126101
this._host.addController(this);
127102
}
128103

129-
public resolveAnchor(value: Element | string | undefined): void {
130-
const resolvedAnchor =
131-
typeof value === 'string'
132-
? getElementByIdFromRoot(this._host, value)
133-
: (value ?? null);
104+
//#region Internal event listeners state
105+
106+
private _addAnchorListeners(): void {
107+
if (!this._anchor) return;
108+
109+
this._anchorAbortController = new AbortController();
110+
const signal = this._anchorAbortController.signal;
111+
112+
for (const each of this._showTriggers) {
113+
this._anchor.addEventListener(each, this, { passive: true, signal });
114+
}
134115

135-
this.setAnchor(resolvedAnchor);
116+
for (const each of this._hideTriggers) {
117+
this._anchor.addEventListener(each, this, { passive: true, signal });
118+
}
119+
}
120+
121+
private _removeAnchorListeners(): void {
122+
this._anchorAbortController?.abort();
123+
this._anchorAbortController = null;
136124
}
137125

138126
private _addTooltipListeners(): void {
139-
this._host.addEventListener('pointerenter', this, { passive: true });
140-
this._host.addEventListener('pointerleave', this, { passive: true });
127+
this._hostAbortController = new AbortController();
128+
const signal = this._hostAbortController.signal;
129+
130+
this._host.addEventListener('pointerenter', this, {
131+
passive: true,
132+
signal,
133+
});
134+
this._host.addEventListener('pointerleave', this, {
135+
passive: true,
136+
signal,
137+
});
141138
}
142139

143140
private _removeTooltipListeners(): void {
144-
this._host.removeEventListener('pointerenter', this);
145-
this._host.removeEventListener('pointerleave', this);
141+
this._hostAbortController?.abort();
142+
this._hostAbortController = null;
146143
}
147144

148-
private _toggleTriggers(previous: Set<string>, current: Set<string>): void {
149-
for (const each of previous) {
150-
this._anchor?.removeEventListener(each, this);
151-
}
145+
//#endregion
152146

153-
for (const each of current) {
154-
this._anchor?.addEventListener(each, this, { passive: true });
147+
//#region Event handlers
148+
149+
private _handleTooltipEvent(event: Event): void {
150+
switch (event.type) {
151+
case 'pointerenter':
152+
this._options.onShow.call(this._host);
153+
break;
154+
case 'pointerleave':
155+
this._options.onHide.call(this._host);
155156
}
156157
}
157158

158-
private _dispose(): void {
159-
for (const each of this._showTriggers) {
160-
this._anchor?.removeEventListener(each, this);
159+
private _handleAnchorEvent(event: Event): void {
160+
if (!this._open && this._showTriggers.has(event.type)) {
161+
this._options.onShow.call(this._host);
161162
}
162163

163-
for (const each of this._hideTriggers) {
164-
this._anchor?.removeEventListener(each, this);
164+
if (this._open && this._hideTriggers.has(event.type)) {
165+
this._options.onHide.call(this._host);
166+
}
167+
}
168+
169+
/** @internal */
170+
public handleEvent(event: Event): void {
171+
if (event.target === this._host) {
172+
this._handleTooltipEvent(event);
173+
} else if (event.target === this._anchor) {
174+
this._handleAnchorEvent(event);
165175
}
176+
}
177+
178+
//#endregion
166179

180+
private _dispose(): void {
181+
this._removeAnchorListeners();
167182
this._anchor = null;
168183
}
169184

185+
//#region Public API
186+
187+
/**
188+
* Removes all triggers from the previous `anchor` target and rebinds the current
189+
* sets back to the new value if it exists.
190+
*/
191+
public setAnchor(value: TooltipAnchor, transient = false): void {
192+
if (this._anchor === value) return;
193+
194+
this._removeAnchorListeners();
195+
this._anchor = value;
196+
this._isTransientAnchor = transient;
197+
this._addAnchorListeners();
198+
}
199+
200+
public resolveAnchor(value: Element | string | undefined): void {
201+
this.setAnchor(
202+
isString(value) ? getElementByIdFromRoot(this._host, value) : value
203+
);
204+
}
205+
206+
//#endregion
207+
208+
//#region ReactiveController interface
209+
170210
/** @internal */
171211
public hostConnected(): void {
172-
const anchor = this._host.anchor;
173-
this.resolveAnchor(anchor);
212+
this.resolveAnchor(this._host.anchor);
174213
}
175214

176215
/** @internal */
@@ -180,33 +219,7 @@ class TooltipController implements ReactiveController {
180219
service.remove(this._host);
181220
}
182221

183-
/** @internal */
184-
public handleEvent(event: Event): void {
185-
// Tooltip handlers
186-
if (event.target === this._host) {
187-
switch (event.type) {
188-
case 'pointerenter':
189-
this._options.onShow.call(this._host);
190-
break;
191-
case 'pointerleave':
192-
this._options.onHide.call(this._host);
193-
break;
194-
default:
195-
return;
196-
}
197-
}
198-
199-
// Anchor handlers
200-
if (event.target === this._anchor) {
201-
if (this._showTriggers.has(event.type) && !this._open) {
202-
this._options.onShow.call(this._host);
203-
}
204-
205-
if (this._hideTriggers.has(event.type) && this._open) {
206-
this._options.onHide.call(this._host);
207-
}
208-
}
209-
}
222+
//#endregion
210223
}
211224

212225
function parseTriggers(string: string): Set<string> {
@@ -219,3 +232,11 @@ export function addTooltipController(
219232
): TooltipController {
220233
return new TooltipController(host, options);
221234
}
235+
236+
type TooltipAnchor = Element | null | undefined;
237+
238+
type TooltipCallbacks = {
239+
onShow: (event?: Event) => unknown;
240+
onHide: (event?: Event) => unknown;
241+
onEscape: (event?: Event) => unknown;
242+
};

src/components/tooltip/tooltip.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -428,7 +428,7 @@ export default class IgcTooltipComponent extends EventEmitterMixin<
428428
shift
429429
>
430430
<div ${ref(this._containerRef)} part="base">
431-
<slot>${this.message ? html`${this.message}` : nothing}</slot>
431+
<slot>${this.message}</slot>
432432
${this.sticky
433433
? html`
434434
<slot name="close-button" @click=${this._setAutoHide}>

stories/tooltip.stories.ts

Lines changed: 16 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -536,48 +536,29 @@ export const DynamicTooltip: Story = {
536536

537537
export const SharedTooltipMultipleAnchors: Story = {
538538
render: () => {
539-
const tooltipId = 'shared-tooltip';
540-
541-
setTimeout(() => {
542-
const tooltip = document.getElementById(tooltipId) as any;
543-
const elementTooltip = document.querySelector(
544-
'igc-tooltip:not([id])'
545-
) as any;
546-
const elementButton = document.getElementById('elementButton');
547-
548-
// Set anchor for second tooltip dynamically
549-
if (elementTooltip && elementButton) {
550-
elementTooltip.anchor = elementButton;
551-
elementTooltip.show();
552-
}
553-
554-
document.querySelectorAll('.tooltip-trigger').forEach((btn) => {
555-
btn.addEventListener('click', async () => {
556-
tooltip.show(btn);
557-
});
558-
});
559-
});
560-
561539
return html`
562540
<div style="display: flex; gap: 1rem; margin-bottom: 2rem;">
563541
<igc-button id="first">Default Anchor</igc-button>
564-
<igc-button class="tooltip-trigger">Transient 1</igc-button>
565-
<igc-button class="tooltip-trigger">Transient 2</igc-button>
566-
<igc-button id="elementButton">Element Anchor</igc-button>
542+
<igc-button
543+
onpointerenter="sharedTooltip.show(transient1)"
544+
id="transient1"
545+
>Transient 1</igc-button
546+
>
547+
<igc-button
548+
onpointerenter="sharedTooltip.show(transient2)"
549+
id="transient2"
550+
>Transient 2</igc-button
551+
>
552+
<igc-button
553+
onpointerenter="sharedTooltip.show(transient3)"
554+
id="transient3"
555+
>Transient 3</igc-button
556+
>
567557
</div>
568558
569-
<igc-tooltip
570-
anchor="first"
571-
id="shared-tooltip"
572-
placement="top"
573-
?sticky=${true}
574-
>
559+
<igc-tooltip anchor="first" id="sharedTooltip" placement="top">
575560
This is a shared tooltip!
576561
</igc-tooltip>
577-
578-
<igc-tooltip
579-
message="This is a tooltip with an element anchor"
580-
></igc-tooltip>
581562
`;
582563
},
583564
};

0 commit comments

Comments
 (0)