Skip to content

Commit 99f16f9

Browse files
committed
refactor(tooltip): implement dynamic anchor change for the tooltip
* Moved handling of anchor resolution and updates to the event controller. * Added 'click' to the default hide-triggers * Tooltip `show()` can now accept a target element to allow transient anchors. * Refactored tests and added a check to ensure `igcClosed` fires on Escape key.
1 parent 004b948 commit 99f16f9

File tree

4 files changed

+147
-41
lines changed

4 files changed

+147
-41
lines changed

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

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { ReactiveController } from 'lit';
2+
import { getElementByIdFromRoot } from '../common/util.js';
23
import service from './tooltip-service.js';
34
import type IgcTooltipComponent from './tooltip.js';
45

@@ -13,10 +14,11 @@ type TooltipCallbacks = {
1314
class TooltipController implements ReactiveController {
1415
private readonly _host: IgcTooltipComponent;
1516
private _anchor: TooltipAnchor;
17+
private _isTransientAnchor = false;
1618

1719
private _options!: TooltipCallbacks;
1820
private _showTriggers = new Set(['pointerenter']);
19-
private _hideTriggers = new Set(['pointerleave']);
21+
private _hideTriggers = new Set(['pointerleave', 'click']);
2022

2123
private _open = false;
2224

@@ -35,6 +37,11 @@ class TooltipController implements ReactiveController {
3537
} else {
3638
this._removeTooltipListeners();
3739
service.remove(this._host);
40+
41+
if (this._isTransientAnchor) {
42+
this._dispose();
43+
this._isTransientAnchor = false;
44+
}
3845
}
3946
}
4047

@@ -49,9 +56,12 @@ class TooltipController implements ReactiveController {
4956
* Removes all triggers from the previous `anchor` target and rebinds the current
5057
* sets back to the new value if it exists.
5158
*/
52-
public set anchor(value: TooltipAnchor) {
59+
public setAnchor(value: TooltipAnchor, transient = false): void {
60+
if (this._anchor === value) return;
61+
5362
this._dispose();
5463
this._anchor = value;
64+
this._isTransientAnchor = transient;
5565

5666
for (const each of this._showTriggers) {
5767
this._anchor?.addEventListener(each, this);
@@ -116,6 +126,15 @@ class TooltipController implements ReactiveController {
116126
this._host.addController(this);
117127
}
118128

129+
public resolveAnchor(value: Element | string | undefined): void {
130+
const resolvedAnchor =
131+
typeof value === 'string'
132+
? getElementByIdFromRoot(this._host, value)
133+
: (value ?? null);
134+
135+
this.setAnchor(resolvedAnchor);
136+
}
137+
119138
private _addTooltipListeners(): void {
120139
this._host.addEventListener('pointerenter', this, { passive: true });
121140
this._host.addEventListener('pointerleave', this, { passive: true });
@@ -148,6 +167,14 @@ class TooltipController implements ReactiveController {
148167
this._anchor = null;
149168
}
150169

170+
/** @internal */
171+
public hostConnected(): void {
172+
const attr = this._host.getAttribute('anchor');
173+
if (attr) {
174+
this.resolveAnchor(attr);
175+
}
176+
}
177+
151178
/** @internal */
152179
public hostDisconnected(): void {
153180
this._dispose();

src/components/tooltip/tooltip.spec.ts

Lines changed: 44 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ describe('Tooltip', () => {
7272
expect(tooltip.placement).to.equal('top');
7373
expect(tooltip.anchor).to.be.undefined;
7474
expect(tooltip.showTriggers).to.equal('pointerenter');
75-
expect(tooltip.hideTriggers).to.equal('pointerleave');
75+
expect(tooltip.hideTriggers).to.equal('pointerleave,click');
7676
expect(tooltip.showDelay).to.equal(200);
7777
expect(tooltip.hideDelay).to.equal(300);
7878
expect(tooltip.message).to.equal('');
@@ -168,18 +168,13 @@ describe('Tooltip', () => {
168168
simulatePointerEnter(third);
169169
await clock.tickAsync(DEFAULT_SHOW_DELAY);
170170
await showComplete();
171-
expect(tooltip.open).to.be.true;
171+
expect(tooltip.open).to.be.false;
172172

173173
simulatePointerLeave(third);
174174
await clock.tickAsync(endTick(DEFAULT_HIDE_DELAY));
175175
await hideComplete();
176176
expect(tooltip.open).to.be.false;
177177

178-
simulatePointerEnter(first);
179-
await clock.tickAsync(DEFAULT_SHOW_DELAY);
180-
await showComplete();
181-
expect(tooltip.open).to.be.false;
182-
183178
// By providing an IDREF
184179
tooltip.anchor = first.id;
185180
await elementUpdated(tooltip);
@@ -465,7 +460,7 @@ describe('Tooltip', () => {
465460
describe('Behaviors', () => {
466461
beforeEach(async () => {
467462
clock = useFakeTimers({ toFake: ['setTimeout'] });
468-
const container = await fixture(createDefaultTooltip());
463+
const container = await fixture(createTooltipWithTarget());
469464
anchor = container.querySelector('button')!;
470465
tooltip = container.querySelector(IgcTooltipComponent.tagName)!;
471466
});
@@ -476,7 +471,7 @@ describe('Tooltip', () => {
476471

477472
it('default triggers', async () => {
478473
expect(tooltip.showTriggers).to.equal('pointerenter');
479-
expect(tooltip.hideTriggers).to.equal('pointerleave');
474+
expect(tooltip.hideTriggers).to.equal('pointerleave,click');
480475

481476
simulatePointerEnter(anchor);
482477
await clock.tickAsync(DEFAULT_SHOW_DELAY);
@@ -490,8 +485,8 @@ describe('Tooltip', () => {
490485
});
491486

492487
it('custom triggers via property', async () => {
493-
tooltip.showTriggers = 'focus,click';
494-
tooltip.hideTriggers = 'blur';
488+
tooltip.showTriggers = 'focus, pointerenter';
489+
tooltip.hideTriggers = 'blur, click';
495490

496491
simulateFocus(anchor);
497492
await clock.tickAsync(DEFAULT_SHOW_DELAY);
@@ -503,12 +498,12 @@ describe('Tooltip', () => {
503498
await hideComplete();
504499
expect(tooltip.open).to.be.false;
505500

506-
simulateClick(anchor);
501+
simulatePointerEnter(anchor);
507502
await clock.tickAsync(DEFAULT_SHOW_DELAY);
508503
await showComplete();
509504
expect(tooltip.open).to.be.true;
510505

511-
simulateBlur(anchor);
506+
simulateClick(anchor);
512507
await clock.tickAsync(endTick(DEFAULT_HIDE_DELAY));
513508
await hideComplete();
514509
expect(tooltip.open).to.be.false;
@@ -517,8 +512,11 @@ describe('Tooltip', () => {
517512
it('custom triggers via attribute', async () => {
518513
const template = html`
519514
<div>
520-
<button>Hover over me</button>
521-
<igc-tooltip show-triggers="focus,click" hide-triggers="blur"
515+
<button id="anchor1">Hover over me</button>
516+
<igc-tooltip
517+
anchor="anchor1"
518+
show-triggers="focus,click"
519+
hide-triggers="blur"
522520
>I am a tooltip</igc-tooltip
523521
>
524522
</div>
@@ -594,7 +592,7 @@ describe('Tooltip', () => {
594592

595593
beforeEach(async () => {
596594
clock = useFakeTimers({ toFake: ['setTimeout'] });
597-
const container = await fixture(createDefaultTooltip());
595+
const container = await fixture(createTooltipWithTarget());
598596
tooltip = container.querySelector(IgcTooltipComponent.tagName)!;
599597
anchor = container.querySelector('button')!;
600598
eventSpy = spy(tooltip, 'emitEvent');
@@ -667,6 +665,32 @@ describe('Tooltip', () => {
667665
detail: anchor,
668666
});
669667
});
668+
669+
it('fires `igcClosed` when tooltip is hidden via Escape key', async () => {
670+
simulatePointerEnter(anchor);
671+
await clock.tickAsync(DEFAULT_SHOW_DELAY);
672+
await showComplete(tooltip);
673+
674+
eventSpy.resetHistory();
675+
676+
document.documentElement.dispatchEvent(
677+
new KeyboardEvent('keydown', {
678+
key: 'Escape',
679+
bubbles: true,
680+
composed: true,
681+
})
682+
);
683+
684+
await clock.tickAsync(endTick(DEFAULT_HIDE_DELAY));
685+
await hideComplete(tooltip);
686+
687+
expect(tooltip.open).to.be.false;
688+
expect(eventSpy.callCount).to.equal(1);
689+
expect(eventSpy.firstCall).calledWith('igcClosed', {
690+
cancelable: false,
691+
detail: anchor,
692+
});
693+
});
670694
});
671695

672696
describe('Keyboard interactions', () => {
@@ -771,10 +795,10 @@ function createTooltipWithTarget(isOpen = false) {
771795
function createTooltips() {
772796
return html`
773797
<div>
774-
<button>Target 1</button>
775-
<igc-tooltip>First</igc-tooltip>
776-
<button>Target 2</button>
777-
<igc-tooltip>Second</igc-tooltip>
798+
<button id="firstTarget">Target 1</button>
799+
<igc-tooltip anchor="firstTarget">First</igc-tooltip>
800+
<button id="secondTarget">Target 2</button>
801+
<igc-tooltip anchor="secondTarget">Second</igc-tooltip>
778802
</div>
779803
`;
780804
}

src/components/tooltip/tooltip.ts

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { watch } from '../common/decorators/watch.js';
1010
import { registerComponent } from '../common/definitions/register.js';
1111
import type { Constructor } from '../common/mixins/constructor.js';
1212
import { EventEmitterMixin } from '../common/mixins/event-emitter.js';
13-
import { asNumber, getElementByIdFromRoot, isString } from '../common/util.js';
13+
import { asNumber } from '../common/util.js';
1414
import IgcIconComponent from '../icon/icon.js';
1515
import IgcPopoverComponent, { type IgcPlacement } from '../popover/popover.js';
1616
import { styles as shared } from './themes/shared/tooltip.common.css';
@@ -168,7 +168,7 @@ export default class IgcTooltipComponent extends EventEmitterMixin<
168168
* Expects a comma separate string of different event triggers.
169169
*
170170
* @attr hide-triggers
171-
* @default pointerleave
171+
* @default "pointerleave, click"
172172
*/
173173
@property({ attribute: 'hide-triggers' })
174174
public set hideTriggers(value: string) {
@@ -236,8 +236,6 @@ export default class IgcTooltipComponent extends EventEmitterMixin<
236236
}
237237

238238
protected override firstUpdated(): void {
239-
this._controller.anchor ??= this.previousElementSibling;
240-
241239
if (this.open) {
242240
this.updateComplete.then(() => {
243241
this._player.playExclusive(this._showAnimation);
@@ -247,12 +245,8 @@ export default class IgcTooltipComponent extends EventEmitterMixin<
247245
}
248246

249247
@watch('anchor')
250-
protected _anchorChanged(): void {
251-
const target = isString(this.anchor)
252-
? getElementByIdFromRoot(this, this.anchor)
253-
: this.anchor;
254-
255-
this._controller.anchor = target;
248+
protected _onAnchorChange() {
249+
this._controller.resolveAnchor(this.anchor);
256250
}
257251

258252
@watch('sticky')
@@ -318,7 +312,17 @@ export default class IgcTooltipComponent extends EventEmitterMixin<
318312
}
319313

320314
/** Shows the tooltip if not already showing. */
321-
public show(): Promise<boolean> {
315+
public show(target?: Element): Promise<boolean> {
316+
if (target) {
317+
clearTimeout(this._timeoutId);
318+
this._player.stopAll();
319+
320+
if (this._controller.anchor !== target) {
321+
this.open = false;
322+
}
323+
this._controller.setAnchor(target, true);
324+
}
325+
322326
return this._applyTooltipState({ show: true });
323327
}
324328

0 commit comments

Comments
 (0)