Skip to content

Commit 699906e

Browse files
committed
Merge branch 'aahmedov/add-tooltip-web-components' into rkaraivanov/tooltip
2 parents 6eab574 + 7af8977 commit 699906e

File tree

4 files changed

+229
-29
lines changed

4 files changed

+229
-29
lines changed
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { EaseOut } from '../../easings.js';
2+
import { animation } from '../../types.js';
3+
4+
const baseOptions: KeyframeAnimationOptions = {
5+
duration: 350,
6+
easing: EaseOut.Quad,
7+
};
8+
9+
const scaleInCenter = (options = baseOptions) =>
10+
animation(
11+
[
12+
{ transform: 'scale(0)', opacity: 0 },
13+
{ transform: 'scale(1)', opacity: 1 },
14+
],
15+
options
16+
);
17+
18+
export { scaleInCenter };

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

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,11 @@ class TooltipController implements ReactiveController {
3333
this.remove(anchor);
3434

3535
for (const trigger of show) {
36-
anchor.addEventListener(trigger, this._host.show);
36+
anchor.addEventListener(trigger, this._host[showOnTrigger]);
3737
}
3838

3939
for (const trigger of hide) {
40-
anchor.addEventListener(trigger, this._host[hideOnTrigger]);
40+
anchor.addEventListener(trigger, (ev) => this._host[hideOnTrigger](ev));
4141
}
4242
}
4343

@@ -48,7 +48,7 @@ class TooltipController implements ReactiveController {
4848
}
4949

5050
for (const trigger of this._showTriggers) {
51-
anchor.removeEventListener(trigger, this._host.show);
51+
anchor.removeEventListener(trigger, this._host[showOnTrigger]);
5252
}
5353

5454
for (const trigger of this._hideTriggers) {
@@ -58,17 +58,18 @@ class TooltipController implements ReactiveController {
5858

5959
/** @internal */
6060
public hostConnected(): void {
61-
this._host.addEventListener('pointerenter', this._host.show);
61+
this._host.addEventListener('pointerenter', this._host[showOnTrigger]);
6262
this._host.addEventListener('pointerleave', this._host[hideOnTrigger]);
6363
}
6464

6565
/** @internal */
6666
public hostDisconnected(): void {
67-
this._host.removeEventListener('pointerenter', this._host.show);
67+
this._host.removeEventListener('pointerenter', this._host[showOnTrigger]);
6868
this._host.removeEventListener('pointerleave', this._host[hideOnTrigger]);
6969
}
7070
}
7171

72+
export const showOnTrigger = Symbol();
7273
export const hideOnTrigger = Symbol();
7374

7475
export function addTooltipController(

src/components/tooltip/tooltip.ts

Lines changed: 168 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,30 @@
11
import { LitElement, html, nothing } from 'lit';
22
import { property, query } from 'lit/decorators.js';
33
import { type Ref, createRef, ref } from 'lit/directives/ref.js';
4+
import { EaseOut } from '../../animations/easings.js';
45
import { addAnimationController } from '../../animations/player.js';
5-
import { fadeIn, fadeOut } from '../../animations/presets/fade/index.js';
6+
import { fadeOut } from '../../animations/presets/fade/index.js';
7+
import { scaleInCenter } from '../../animations/presets/scale/index.js';
68
import { watch } from '../common/decorators/watch.js';
79
import { registerComponent } from '../common/definitions/register.js';
10+
import type { Constructor } from '../common/mixins/constructor.js';
11+
import { EventEmitterMixin } from '../common/mixins/event-emitter.js';
812
import { getElementByIdFromRoot, isString } from '../common/util.js';
913
import IgcPopoverComponent, { type IgcPlacement } from '../popover/popover.js';
1014
import { styles } from './themes/tooltip.base.css.js';
1115
import {
1216
addTooltipController,
1317
hideOnTrigger,
18+
showOnTrigger,
1419
} from './tooltip-event-controller.js';
1520
import service from './tooltip-service.js';
1621

17-
// TODO: Expose events
22+
export interface IgcTooltipComponentEventMap {
23+
igcOpening: CustomEvent<Element | null>;
24+
igcOpened: CustomEvent<Element | null>;
25+
igcClosing: CustomEvent<Element | null>;
26+
igcClosed: CustomEvent<Element | null>;
27+
}
1828

1929
function parseTriggers(string: string): string[] {
2030
return (string ?? '').split(',').map((part) => part.trim());
@@ -24,11 +34,19 @@ function parseTriggers(string: string): string[] {
2434
* @element igc-tooltip
2535
*
2636
* @slot - default slot
37+
*
38+
* @fires igcOpening - Emitted before the tooltip begins to open. Can be canceled to prevent opening.
39+
* @fires igcOpened - Emitted after the tooltip has successfully opened and is visible.
40+
* @fires igcClosing - Emitted before the tooltip begins to close. Can be canceled to prevent closing.
41+
* @fires igcClosed - Emitted after the tooltip has been fully removed from view.
2742
*/
28-
export default class IgcTooltipComponent extends LitElement {
43+
export default class IgcTooltipComponent extends EventEmitterMixin<
44+
IgcTooltipComponentEventMap,
45+
Constructor<LitElement>
46+
>(LitElement) {
2947
public static readonly tagName = 'igc-tooltip';
3048

31-
public static override styles = styles;
49+
public static styles = styles;
3250

3351
/* blazorSuppress */
3452
public static register() {
@@ -40,10 +58,14 @@ export default class IgcTooltipComponent extends LitElement {
4058
private _containerRef: Ref<HTMLElement> = createRef();
4159
private _animationPlayer = addAnimationController(this, this._containerRef);
4260

43-
private _autoHideTimeout?: number;
61+
private _timeoutId?: number;
62+
private _toBeShown = false;
63+
private _toBeHidden = false;
4464
private _open = false;
4565
private _showTriggers = ['pointerenter'];
4666
private _hideTriggers = ['pointerleave'];
67+
private _showDelay = 500;
68+
private _hideDelay = 500;
4769

4870
@query('#arrow')
4971
protected _arrowElement!: HTMLElement;
@@ -133,19 +155,57 @@ export default class IgcTooltipComponent extends LitElement {
133155
this._hideTriggers = parseTriggers(value);
134156
this._controller.set(this._target, {
135157
show: this._showTriggers,
136-
hide: this._showTriggers,
158+
hide: this._hideTriggers,
137159
});
138160
}
139161

140162
public get hideTriggers(): string {
141163
return this._hideTriggers.join();
142164
}
143165

166+
/**
167+
* Specifies the number of milliseconds that should pass before showing the tooltip.
168+
*
169+
* @attr show-delay
170+
*/
171+
@property({ attribute: 'show-delay', type: Number })
172+
public set showDelay(value: number) {
173+
this._showDelay = Math.max(0, value);
174+
}
175+
176+
public get showDelay(): number {
177+
return this._showDelay;
178+
}
179+
180+
/**
181+
* Specifies the number of milliseconds that should pass before hiding the tooltip.
182+
*
183+
* @attr hide-delay
184+
*/
185+
@property({ attribute: 'hide-delay', type: Number })
186+
public set hideDelay(value: number) {
187+
this._hideDelay = Math.max(0, value);
188+
}
189+
190+
public get hideDelay(): number {
191+
return this._hideDelay;
192+
}
193+
194+
/**
195+
* Specifies a plain text as tooltip content.
196+
*
197+
* @attr
198+
*/
199+
@property({ type: String })
200+
public message = '';
201+
144202
constructor() {
145203
super();
146204

147205
this._internals = this.attachInternals();
148206
this._internals.role = 'tooltip';
207+
this._internals.ariaAtomic = 'true';
208+
this._internals.ariaLive = 'polite';
149209
}
150210

151211
protected override async firstUpdated() {
@@ -189,40 +249,124 @@ export default class IgcTooltipComponent extends LitElement {
189249
}
190250

191251
private async _toggleAnimation(dir: 'open' | 'close') {
192-
const animation = dir === 'open' ? fadeIn : fadeOut;
193-
return this._animationPlayer.playExclusive(animation());
252+
const animation =
253+
dir === 'open'
254+
? scaleInCenter({ duration: 150, easing: EaseOut.Quad })
255+
: fadeOut({ duration: 75, easing: EaseOut.Sine });
256+
return this._animationPlayer.playExclusive(animation);
257+
}
258+
259+
/**
260+
* Immediately stops any ongoing animation and resets the tooltip state.
261+
*
262+
* This method is used in edge cases when a transition needs to be forcefully interrupted,
263+
* such as when a tooltip is in the middle of showing or hiding and the user suddenly
264+
* triggers the opposite action (e.g., hovers in and out rapidly).
265+
*
266+
* It:
267+
* - Reverts `open` based on whether it was mid-hide or mid-show.
268+
* - Clears internal transition flags (`_toBeShown`, `_toBeHidden`).
269+
* - Stops any active animations, causing `_toggleAnimation()` to return false.
270+
*
271+
*/
272+
private async _forceAnimationStop() {
273+
this.open = this._toBeHidden;
274+
this._toBeShown = false;
275+
this._toBeHidden = false;
276+
this._animationPlayer.stopAll();
277+
}
278+
279+
private _setDelay(ms: number): Promise<void> {
280+
clearTimeout(this._timeoutId);
281+
return new Promise((resolve) => {
282+
this._timeoutId = setTimeout(resolve, ms);
283+
});
194284
}
195285

196286
/** Shows the tooltip if not already showing. */
197-
public show = async () => {
198-
clearTimeout(this._autoHideTimeout);
199-
if (this.open) {
200-
return false;
201-
}
287+
public show = async (): Promise<boolean> => {
288+
if (this.open) return false;
289+
290+
await this._setDelay(this.showDelay);
202291

203292
this.open = true;
204-
return await this._toggleAnimation('open');
293+
this._toBeShown = true;
294+
const result = await this._toggleAnimation('open');
295+
this._toBeShown = false;
296+
297+
return result;
205298
};
206299

207300
/** Hides the tooltip if not already hidden. */
208-
public hide = async () => {
209-
if (!this.open) {
210-
return false;
301+
public hide = async (): Promise<boolean> => {
302+
if (!this.open) return false;
303+
304+
await this._setDelay(this.hideDelay);
305+
306+
this._toBeHidden = true;
307+
const result = await this._toggleAnimation('close');
308+
this.open = !result;
309+
this._toBeHidden = false;
310+
311+
return result;
312+
};
313+
314+
/** Toggles the tooltip between shown/hidden state after the appropriate delay. */
315+
public toggle = async (): Promise<boolean> => {
316+
return this.open ? this.hide() : this.show();
317+
};
318+
319+
public showWithEvent = async () => {
320+
if (this._toBeHidden) {
321+
await this._forceAnimationStop();
322+
return;
323+
}
324+
if (
325+
this.open ||
326+
!this.emitEvent('igcOpening', { cancelable: true, detail: this._target })
327+
) {
328+
return;
329+
}
330+
if (await this.show()) {
331+
this.emitEvent('igcOpened', { detail: this._target });
332+
}
333+
};
334+
335+
public hideWithEvent = async () => {
336+
if (this._toBeShown) {
337+
await this._forceAnimationStop();
338+
return;
339+
}
340+
if (
341+
!this.open ||
342+
!this.emitEvent('igcClosing', { cancelable: true, detail: this._target })
343+
) {
344+
return;
345+
}
346+
if (await this.hide()) {
347+
this.emitEvent('igcClosed', { detail: this._target });
211348
}
349+
};
212350

213-
await this._toggleAnimation('close');
214-
this.open = false;
215-
clearTimeout(this._autoHideTimeout);
216-
return true;
351+
protected [showOnTrigger] = () => {
352+
clearTimeout(this._timeoutId);
353+
this.showWithEvent();
217354
};
218355

219-
protected [hideOnTrigger] = () => {
220-
this._autoHideTimeout = setTimeout(() => this.hide(), 180);
356+
protected [hideOnTrigger] = (ev: Event) => {
357+
const related = (ev as PointerEvent).relatedTarget as Node | null;
358+
// If the pointer moved into the tooltip element, don't hide
359+
if (related && (this.contains(related) || this._target?.contains(related)))
360+
return;
361+
362+
clearTimeout(this._timeoutId);
363+
this._timeoutId = setTimeout(() => this.hideWithEvent(), 180);
221364
};
222365

223366
protected override render() {
224367
return html`
225368
<igc-popover
369+
aria-hidden=${!this.open}
226370
.placement=${this.placement}
227371
.offset=${this.offset}
228372
.anchor=${this._target}
@@ -233,7 +377,7 @@ export default class IgcTooltipComponent extends LitElement {
233377
shift
234378
>
235379
<div ${ref(this._containerRef)} part="base">
236-
<slot></slot>
380+
${this.message ? html`${this.message}` : html`<slot></slot>`}
237381
${this.disableArrow ? nothing : html`<div id="arrow"></div>`}
238382
</div>
239383
</igc-popover>

stories/tooltip.stories.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,8 @@ export const Basic: Story = {
189189
190190
<igc-input label="Password" required minlength="12"></igc-input>
191191
<igc-tooltip
192+
.showDelay=${args.showDelay}
193+
.hideDelay=${args.hideDelay}
192194
placement="bottom-end"
193195
offset="-4"
194196
disable-arrow
@@ -286,3 +288,38 @@ export const Triggers: Story = {
286288
</igc-tooltip>
287289
`,
288290
};
291+
292+
export const Toggle: Story = {
293+
render: () => {
294+
// Use a template ref id to target the tooltip instance
295+
const tooltipId = 'toggle-tooltip';
296+
const buttonId = 'toggle-button';
297+
const buttonIdToggler = 'toggler-button';
298+
299+
// Hook into the rendered DOM to attach click listener
300+
setTimeout(() => {
301+
const tooltip = document.getElementById(tooltipId) as IgcTooltipComponent;
302+
const button = document.getElementById(
303+
buttonIdToggler
304+
) as HTMLButtonElement;
305+
306+
if (tooltip && button) {
307+
button.addEventListener('click', () => tooltip.toggle());
308+
}
309+
});
310+
311+
return html`
312+
<igc-button id=${buttonIdToggler}>Toggle</igc-button>
313+
<igc-button id=${buttonId}>Toggle Tooltip</igc-button>
314+
<igc-tooltip
315+
id=${tooltipId}
316+
placement="bottom"
317+
show-delay="500"
318+
hide-delay="500"
319+
message="Simple tooltip content"
320+
>
321+
This tooltip toggles on button click!
322+
</igc-tooltip>
323+
`;
324+
},
325+
};

0 commit comments

Comments
 (0)