Skip to content

Commit 7c027ff

Browse files
committed
refactor(tooltip): Anchor targets as weak refs
* Anchor event listeners as wrapped weak references. * `show` method now accepts an IDREF in addition to the element reference. * Some code reorganization. * Included the tooltip component inside the `defineAllComponents` collection.
1 parent 105d6e7 commit 7c027ff

File tree

6 files changed

+142
-49
lines changed

6 files changed

+142
-49
lines changed

src/components/common/definitions/defineAllComponents.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import IgcTextareaComponent from '../../textarea/textarea.js';
6363
import IgcTileManagerComponent from '../../tile-manager/tile-manager.js';
6464
import IgcTileComponent from '../../tile-manager/tile.js';
6565
import IgcToastComponent from '../../toast/toast.js';
66+
import IgcTooltipComponent from '../../tooltip/tooltip.js';
6667
import IgcTreeItemComponent from '../../tree/tree-item.js';
6768
import IgcTreeComponent from '../../tree/tree.js';
6869
import { defineComponents } from './defineComponents.js';
@@ -136,6 +137,7 @@ const allComponents: IgniteComponent[] = [
136137
IgcTextareaComponent,
137138
IgcTileComponent,
138139
IgcTileManagerComponent,
140+
IgcTooltipComponent,
139141
];
140142

141143
export function defineAllComponents() {

src/components/common/util.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,32 @@ export function isString(value: unknown): value is string {
292292
return typeof value === 'string';
293293
}
294294

295+
export function isObject(value: unknown): value is object {
296+
return value != null && typeof value === 'object';
297+
}
298+
299+
export function isEventListenerObject(x: unknown): x is EventListenerObject {
300+
return isObject(x) && 'handleEvent' in x;
301+
}
302+
303+
export function addWeakEventListener(
304+
element: Element,
305+
event: string,
306+
listener: EventListenerOrEventListenerObject,
307+
options?: AddEventListenerOptions | boolean
308+
): void {
309+
const weakRef = new WeakRef(listener);
310+
const wrapped = (evt: Event) => {
311+
const handler = weakRef.deref();
312+
313+
return isEventListenerObject(handler)
314+
? handler.handleEvent(evt)
315+
: handler?.(evt);
316+
};
317+
318+
element.addEventListener(event, wrapped, options);
319+
}
320+
295321
/**
296322
* Returns whether a given collection is empty.
297323
*/

src/components/tooltip/tooltip-event-controller.ts renamed to src/components/tooltip/controller.ts

Lines changed: 60 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
11
import type { ReactiveController } from 'lit';
2-
import { getElementByIdFromRoot, isString } from '../common/util.js';
3-
import service from './tooltip-service.js';
2+
import {
3+
addWeakEventListener,
4+
getElementByIdFromRoot,
5+
isString,
6+
} from '../common/util.js';
7+
import service from './service.js';
48
import type IgcTooltipComponent from './tooltip.js';
59

610
class TooltipController implements ReactiveController {
711
//#region Internal properties and state
812

13+
private static readonly _listeners = [
14+
'pointerenter',
15+
'pointerleave',
16+
] as const;
17+
918
private readonly _host: IgcTooltipComponent;
1019
private readonly _options: TooltipCallbacks;
1120

@@ -15,9 +24,10 @@ class TooltipController implements ReactiveController {
1524
private _showTriggers = new Set(['pointerenter']);
1625
private _hideTriggers = new Set(['pointerleave', 'click']);
1726

18-
private _anchor: TooltipAnchor;
19-
private _defaultAnchor: TooltipAnchor;
20-
private _isTransientAnchor = false;
27+
private _anchor: WeakRef<Element> | null = null;
28+
private _initialAnchor: WeakRef<Element> | null = null;
29+
30+
private _isTransient = false;
2131
private _open = false;
2232

2333
//#endregion
@@ -37,9 +47,9 @@ class TooltipController implements ReactiveController {
3747
this._addTooltipListeners();
3848
service.add(this._host, this._options.onEscape);
3949
} else {
40-
if (this._isTransientAnchor) {
41-
this._isTransientAnchor = false;
42-
this.setAnchor(this._defaultAnchor);
50+
if (this._isTransient) {
51+
this._isTransient = false;
52+
this.setAnchor(this._initialAnchor?.deref());
4353
}
4454

4555
this._removeTooltipListeners();
@@ -51,7 +61,9 @@ class TooltipController implements ReactiveController {
5161
* Returns the current tooltip anchor target if any.
5262
*/
5363
public get anchor(): TooltipAnchor {
54-
return this._anchor;
64+
return this._isTransient
65+
? this._anchor?.deref()
66+
: this._initialAnchor?.deref();
5567
}
5668

5769
/**
@@ -105,17 +117,21 @@ class TooltipController implements ReactiveController {
105117
//#region Internal event listeners state
106118

107119
private _addAnchorListeners(): void {
108-
if (!this._anchor) return;
120+
const anchor = this.anchor;
121+
122+
if (!anchor) {
123+
return;
124+
}
109125

110126
this._anchorAbortController = new AbortController();
111127
const signal = this._anchorAbortController.signal;
112128

113129
for (const each of this._showTriggers) {
114-
this._anchor.addEventListener(each, this, { passive: true, signal });
130+
addWeakEventListener(anchor, each, this, { passive: true, signal });
115131
}
116132

117133
for (const each of this._hideTriggers) {
118-
this._anchor.addEventListener(each, this, { passive: true, signal });
134+
addWeakEventListener(anchor, each, this, { passive: true, signal });
119135
}
120136
}
121137

@@ -128,14 +144,9 @@ class TooltipController implements ReactiveController {
128144
this._hostAbortController = new AbortController();
129145
const signal = this._hostAbortController.signal;
130146

131-
this._host.addEventListener('pointerenter', this, {
132-
passive: true,
133-
signal,
134-
});
135-
this._host.addEventListener('pointerleave', this, {
136-
passive: true,
137-
signal,
138-
});
147+
for (const event of TooltipController._listeners) {
148+
this._host.addEventListener(event, this, { passive: true, signal });
149+
}
139150
}
140151

141152
private _removeTooltipListeners(): void {
@@ -147,33 +158,33 @@ class TooltipController implements ReactiveController {
147158

148159
//#region Event handlers
149160

150-
private _handleTooltipEvent(event: Event): void {
161+
private async _handleTooltipEvent(event: Event): Promise<void> {
151162
switch (event.type) {
152163
case 'pointerenter':
153-
this._options.onShow.call(this._host);
164+
await this._options.onShow.call(this._host);
154165
break;
155166
case 'pointerleave':
156-
this._options.onHide.call(this._host);
167+
await this._options.onHide.call(this._host);
157168
}
158169
}
159170

160-
private _handleAnchorEvent(event: Event): void {
171+
private async _handleAnchorEvent(event: Event): Promise<void> {
161172
if (!this._open && this._showTriggers.has(event.type)) {
162-
this._options.onShow.call(this._host);
173+
await this._options.onShow.call(this._host);
163174
}
164175

165176
if (this._open && this._hideTriggers.has(event.type)) {
166-
this._options.onHide.call(this._host);
177+
await this._options.onHide.call(this._host);
167178
}
168179
}
169180

170181
/** @internal */
171182
public handleEvent(event: Event): void {
172183
if (event.target === this._host) {
173184
this._handleTooltipEvent(event);
174-
} else if (event.target === this._anchor) {
185+
} else if (event.target === this._anchor?.deref()) {
175186
this._handleAnchorEvent(event);
176-
} else if (event.target === this._defaultAnchor) {
187+
} else if (event.target === this._initialAnchor?.deref()) {
177188
this.open = false;
178189
this._handleAnchorEvent(event);
179190
}
@@ -183,7 +194,10 @@ class TooltipController implements ReactiveController {
183194

184195
private _dispose(): void {
185196
this._removeAnchorListeners();
197+
this._removeTooltipListeners();
198+
service.remove(this._host);
186199
this._anchor = null;
200+
this._initialAnchor = null;
187201
}
188202

189203
//#region Public API
@@ -192,24 +206,35 @@ class TooltipController implements ReactiveController {
192206
* Removes all triggers from the previous `anchor` target and rebinds the current
193207
* sets back to the new value if it exists.
194208
*/
195-
public setAnchor(value: TooltipAnchor, transient = false): void {
196-
if (this._anchor === value) return;
209+
public setAnchor(value: TooltipAnchor | string, transient = false): void {
210+
const newAnchor = isString(value)
211+
? getElementByIdFromRoot(this._host, value)
212+
: value;
213+
214+
if (this._anchor?.deref() === newAnchor) {
215+
return;
216+
}
217+
218+
// Tooltip `show()` method called with a target. Set to hidden state.
219+
if (transient && this._open) {
220+
this.open = false;
221+
}
197222

198-
if (this._anchor !== this._defaultAnchor) {
223+
if (this._anchor?.deref() !== this._initialAnchor?.deref()) {
199224
this._removeAnchorListeners();
200225
}
201226

202-
this._anchor = value;
203-
this._isTransientAnchor = transient;
227+
this._anchor = newAnchor ? new WeakRef(newAnchor) : null;
228+
this._isTransient = transient;
204229
this._addAnchorListeners();
205230
}
206231

207-
public resolveAnchor(value: Element | string | undefined): void {
232+
public resolveAnchor(value: TooltipAnchor | string): void {
208233
const resolvedElement = isString(value)
209234
? getElementByIdFromRoot(this._host, value)
210235
: value;
211236

212-
this._defaultAnchor = resolvedElement;
237+
this._initialAnchor = resolvedElement ? new WeakRef(resolvedElement) : null;
213238
this.setAnchor(resolvedElement);
214239
}
215240

@@ -225,8 +250,6 @@ class TooltipController implements ReactiveController {
225250
/** @internal */
226251
public hostDisconnected(): void {
227252
this._dispose();
228-
this._removeTooltipListeners();
229-
service.remove(this._host);
230253
}
231254

232255
//#endregion
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,13 @@ class TooltipEscapeCallbacks {
4343
}
4444

4545
/** @internal */
46-
public handleEvent(event: KeyboardEvent): void {
46+
public async handleEvent(event: KeyboardEvent): Promise<void> {
4747
if (event.key !== escapeKey) {
4848
return;
4949
}
5050

5151
const [tooltip, callback] = last(Array.from(this._collection.entries()));
52-
callback?.call(tooltip);
52+
await callback?.call(tooltip);
5353
}
5454
}
5555

src/components/tooltip/tooltip.spec.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,51 @@ describe('Tooltip', () => {
500500
await showComplete();
501501
expect(tooltip.open).to.be.true;
502502
});
503+
504+
it('should be able to pass and IDREF to `show` method', async () => {
505+
const eventSpy = spy(tooltip, 'emitEvent');
506+
507+
const [_, transientAnchor] = Array.from(
508+
tooltip.parentElement!.querySelectorAll('button')
509+
);
510+
511+
transientAnchor.id = 'custom-target';
512+
513+
const result = await tooltip.show('custom-target');
514+
expect(result).to.be.true;
515+
expect(tooltip.open).to.be.true;
516+
expect(eventSpy.callCount).to.equal(0);
517+
});
518+
519+
it('should correctly handle open state and events between default and transient anchors', async () => {
520+
const eventSpy = spy(tooltip, 'emitEvent');
521+
522+
const [defaultAnchor, transientAnchor] = Array.from(
523+
tooltip.parentElement!.querySelectorAll('button')
524+
);
525+
526+
const result = await tooltip.show(transientAnchor);
527+
expect(result).to.be.true;
528+
expect(tooltip.open).to.be.true;
529+
expect(eventSpy.callCount).to.equal(0);
530+
531+
simulatePointerEnter(defaultAnchor);
532+
// Trigger on the initial default anchor. Tooltip must be hidden.
533+
expect(tooltip.open).to.be.false;
534+
await clock.tickAsync(DEFAULT_SHOW_DELAY);
535+
await showComplete();
536+
expect(tooltip.open).to.be.true;
537+
538+
expect(eventSpy).calledWith('igcOpening', {
539+
cancelable: true,
540+
detail: defaultAnchor,
541+
});
542+
543+
expect(eventSpy).calledWith('igcOpened', {
544+
cancelable: false,
545+
detail: defaultAnchor,
546+
});
547+
});
503548
});
504549

505550
describe('Behaviors', () => {

src/components/tooltip/tooltip.ts

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@ import IgcIconComponent from '../icon/icon.js';
1515
import IgcPopoverComponent, {
1616
type PopoverPlacement,
1717
} from '../popover/popover.js';
18+
import { addTooltipController } from './controller.js';
1819
import { styles as shared } from './themes/shared/tooltip.common.css';
1920
import { all } from './themes/themes.js';
2021
import { styles } from './themes/tooltip.base.css.js';
21-
import { addTooltipController } from './tooltip-event-controller.js';
2222

2323
export interface IgcTooltipComponentEventMap {
2424
igcOpening: CustomEvent<Element | null>;
@@ -258,7 +258,7 @@ export default class IgcTooltipComponent extends EventEmitterMixin<
258258
super();
259259

260260
this._internals = this.attachInternals();
261-
this._internals.role = this.sticky ? 'status' : 'tooltip';
261+
this._internals.role = 'tooltip';
262262
this._internals.ariaAtomic = 'true';
263263
this._internals.ariaLive = 'polite';
264264
}
@@ -273,7 +273,7 @@ export default class IgcTooltipComponent extends EventEmitterMixin<
273273
}
274274

275275
@watch('anchor')
276-
protected _onAnchorChange() {
276+
protected _onAnchorChange(): void {
277277
this._controller.resolveAnchor(this.anchor);
278278
}
279279

@@ -328,6 +328,7 @@ export default class IgcTooltipComponent extends EventEmitterMixin<
328328

329329
if (withDelay) {
330330
clearTimeout(this._timeoutId);
331+
331332
return new Promise(() => {
332333
this._timeoutId = setTimeout(
333334
async () => await commitStateChange(),
@@ -343,26 +344,22 @@ export default class IgcTooltipComponent extends EventEmitterMixin<
343344
* Shows the tooltip if not already showing.
344345
* If a target is provided, sets it as a transient anchor.
345346
*/
346-
public show(target?: Element): Promise<boolean> {
347+
public async show(target?: Element | string): Promise<boolean> {
347348
if (target) {
348349
this._stopTimeoutAndAnimation();
349-
350-
if (this._controller.anchor !== target) {
351-
this.open = false;
352-
}
353350
this._controller.setAnchor(target, true);
354351
}
355352

356353
return this._applyTooltipState({ show: true });
357354
}
358355

359356
/** Hides the tooltip if not already hidden. */
360-
public hide(): Promise<boolean> {
357+
public async hide(): Promise<boolean> {
361358
return this._applyTooltipState({ show: false });
362359
}
363360

364361
/** Toggles the tooltip between shown/hidden state */
365-
public toggle(): Promise<boolean> {
362+
public async toggle(): Promise<boolean> {
366363
return this.open ? this.hide() : this.show();
367364
}
368365

0 commit comments

Comments
 (0)