Skip to content

Commit d48bff7

Browse files
committed
feat: Added tooltip component
PoC implementation
1 parent c86a598 commit d48bff7

File tree

11 files changed

+1028
-7
lines changed

11 files changed

+1028
-7
lines changed

src/animations/player.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,15 @@ class AnimationController implements ReactiveController {
7171
);
7272
}
7373

74+
public async playExclusive(animation: AnimationReferenceMetadata) {
75+
const [_, event] = await Promise.all([
76+
this.stopAll(),
77+
this.play(animation),
78+
]);
79+
80+
return event.type === 'finish';
81+
}
82+
7483
public hostConnected() {}
7584
}
7685

src/components/common/util.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -293,9 +293,9 @@ export function isString(value: unknown): value is string {
293293
}
294294

295295
/**
296-
* Returns whether a given collection has at least one member.
296+
* Returns whether a given collection is empty.
297297
*/
298-
export function isEmpty<T, U extends string>(
298+
export function isEmpty<T, U extends object>(
299299
x: ArrayLike<T> | Set<T> | Map<U, T>
300300
): boolean {
301301
return 'length' in x ? x.length < 1 : x.size < 1;

src/components/common/utils.spec.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,14 @@ export function simulatePointerLeave(
202202
);
203203
}
204204

205+
export function simulateFocus(node: Element) {
206+
node.dispatchEvent(new FocusEvent('focus'));
207+
}
208+
209+
export function simulateBlur(node: Element) {
210+
node.dispatchEvent(new FocusEvent('blur'));
211+
}
212+
205213
export function simulatePointerDown(
206214
node: Element,
207215
options?: PointerEventInit,

src/components/popover/popover.ts

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import {
22
type Middleware,
3+
type MiddlewareData,
4+
type Placement,
5+
arrow,
36
autoUpdate,
47
computePosition,
58
flip,
9+
inline,
610
limitShift,
711
offset,
812
shift,
@@ -14,6 +18,7 @@ import { property, query, queryAssignedElements } from 'lit/decorators.js';
1418
import { watch } from '../common/decorators/watch.js';
1519
import { registerComponent } from '../common/definitions/register.js';
1620
import {
21+
first,
1722
getElementByIdFromRoot,
1823
isEmpty,
1924
isString,
@@ -72,6 +77,19 @@ export default class IgcPopoverComponent extends LitElement {
7277
@property()
7378
public anchor?: Element | string;
7479

80+
/**
81+
* Element to render as an "arrow" element for the current popover.
82+
*/
83+
@property({ attribute: false })
84+
public arrow: HTMLElement | null = null;
85+
86+
/**
87+
* Improves positioning for inline reference elements that span over multiple lines.
88+
* Useful for tooltips or similar components.
89+
*/
90+
@property({ type: Boolean, reflect: true })
91+
public inline = false;
92+
7593
/**
7694
* When enabled this changes the placement of the floating element in order to keep it
7795
* in view along the main axis.
@@ -127,7 +145,9 @@ export default class IgcPopoverComponent extends LitElement {
127145
this.open ? this.show() : this.hide();
128146
}
129147

148+
@watch('arrow', { waitUntilFirstUpdate: true })
130149
@watch('flip', { waitUntilFirstUpdate: true })
150+
@watch('inline', { waitUntilFirstUpdate: true })
131151
@watch('offset', { waitUntilFirstUpdate: true })
132152
@watch('placement', { waitUntilFirstUpdate: true })
133153
@watch('sameWidth', { waitUntilFirstUpdate: true })
@@ -187,6 +207,10 @@ export default class IgcPopoverComponent extends LitElement {
187207
middleware.push(offset(this.offset));
188208
}
189209

210+
if (this.inline) {
211+
middleware.push(inline());
212+
}
213+
190214
if (this.shift) {
191215
middleware.push(
192216
shift({
@@ -195,6 +219,10 @@ export default class IgcPopoverComponent extends LitElement {
195219
);
196220
}
197221

222+
if (this.arrow) {
223+
middleware.push(arrow({ element: this.arrow }));
224+
}
225+
198226
if (this.flip) {
199227
middleware.push(flip());
200228
}
@@ -226,17 +254,47 @@ export default class IgcPopoverComponent extends LitElement {
226254
return;
227255
}
228256

229-
const { x, y } = await computePosition(this.target, this._container, {
230-
placement: this.placement ?? 'bottom-start',
231-
middleware: this._createMiddleware(),
232-
strategy: 'fixed',
233-
});
257+
const { x, y, middlewareData, placement } = await computePosition(
258+
this.target,
259+
this._container,
260+
{
261+
placement: this.placement ?? 'bottom-start',
262+
middleware: this._createMiddleware(),
263+
strategy: 'fixed',
264+
}
265+
);
234266

235267
Object.assign(this._container.style, {
236268
left: 0,
237269
top: 0,
238270
transform: `translate(${roundByDPR(x)}px,${roundByDPR(y)}px)`,
239271
});
272+
273+
this._positionArrow(placement, middlewareData);
274+
}
275+
276+
private _positionArrow(placement: Placement, data: MiddlewareData) {
277+
if (!data.arrow) {
278+
return;
279+
}
280+
281+
const { x, y } = data.arrow;
282+
283+
const staticSide = {
284+
top: 'bottom',
285+
right: 'left',
286+
bottom: 'top',
287+
left: 'right',
288+
}[first(placement.split('-'))]!;
289+
290+
// TODO: Clean-up this stuff
291+
Object.assign(this.arrow!.style, {
292+
left: x !== null ? `${roundByDPR(x!)}px` : '',
293+
top: y !== null ? `${roundByDPR(y!)}px` : '',
294+
right: '',
295+
bottom: '',
296+
[staticSide]: '-4px',
297+
});
240298
}
241299

242300
private _anchorSlotChange() {
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
@use 'styles/common/component';
2+
@use 'styles/utilities' as *;
3+
4+
:host {
5+
position: fixed;
6+
display: flex;
7+
align-items: center;
8+
text-align: center;
9+
}
10+
11+
[part="base"] {
12+
@include type-style('body-2');
13+
14+
background: linear-gradient(to bottom, #323232 0%, #3F3F3F 40%, #1C1C1C 150%), linear-gradient(to top, rgb(255 255 255 / 40%) 0%, rgb(0 0 0 / 25%) 200%);
15+
background-blend-mode: multiply;
16+
padding: 0.25rem;
17+
color: #fff;
18+
}
19+
20+
#arrow {
21+
position: absolute;
22+
width: 8px;
23+
height: 8px;
24+
transform: rotate(45deg);
25+
background: inherit;
26+
}
27+
28+
igc-popover::part(container) {
29+
background-color: transparent;
30+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import type { ReactiveController } from 'lit';
2+
import type IgcTooltipComponent from './tooltip.js';
3+
4+
type TooltipAnchor = Element | null | undefined;
5+
type TooltipTriggers = {
6+
show: string[];
7+
hide: string[];
8+
};
9+
10+
class TooltipController implements ReactiveController {
11+
private readonly _host: IgcTooltipComponent;
12+
private _showTriggers: string[] = [];
13+
private _hideTriggers: string[] = [];
14+
15+
constructor(host: IgcTooltipComponent) {
16+
this._host = host;
17+
this._host.addController(this);
18+
}
19+
20+
/**
21+
* Sets the current collections of show/hide triggers on the given anchor for the tooltip.
22+
* Removes any previously set triggers.
23+
*/
24+
public set(anchor: TooltipAnchor, triggers: TooltipTriggers) {
25+
if (!anchor) {
26+
return;
27+
}
28+
29+
const { show, hide } = triggers;
30+
this._showTriggers = show;
31+
this._hideTriggers = hide;
32+
33+
this.remove(anchor);
34+
35+
for (const trigger of show) {
36+
anchor.addEventListener(trigger, this._host.show);
37+
}
38+
39+
for (const trigger of hide) {
40+
anchor.addEventListener(trigger, this._host[hideOnTrigger]);
41+
}
42+
}
43+
44+
/** Removes all tooltip trigger events from the given anchor */
45+
public remove(anchor?: TooltipAnchor) {
46+
if (!anchor) {
47+
return;
48+
}
49+
50+
for (const trigger of this._showTriggers) {
51+
anchor.removeEventListener(trigger, this._host.show);
52+
}
53+
54+
for (const trigger of this._hideTriggers) {
55+
anchor.removeEventListener(trigger, this._host[hideOnTrigger]);
56+
}
57+
}
58+
59+
/** @internal */
60+
public hostConnected(): void {
61+
this._host.addEventListener('pointerenter', this._host.show);
62+
this._host.addEventListener('pointerleave', this._host[hideOnTrigger]);
63+
}
64+
65+
/** @internal */
66+
public hostDisconnected(): void {
67+
this._host.removeEventListener('pointerenter', this._host.show);
68+
this._host.removeEventListener('pointerleave', this._host[hideOnTrigger]);
69+
}
70+
}
71+
72+
export const hideOnTrigger = Symbol();
73+
74+
export function addTooltipController(
75+
host: IgcTooltipComponent
76+
): TooltipController {
77+
return new TooltipController(host);
78+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { isServer } from 'lit';
2+
import { escapeKey } from '../common/controllers/key-bindings.js';
3+
import { isEmpty, last } from '../common/util.js';
4+
import { hideOnTrigger } from './tooltip-event-controller.js';
5+
import type IgcTooltipComponent from './tooltip.js';
6+
7+
class TooltipEscapeCallbacks {
8+
private _collection: Map<IgcTooltipComponent, CallableFunction> = new Map();
9+
10+
/**
11+
* Sets the global Escape key handler for closing any open igc-tooltip instances.
12+
*
13+
*/
14+
private _setListeners(): void {
15+
if (isServer) {
16+
return;
17+
}
18+
19+
if (isEmpty(this._collection)) {
20+
document.documentElement.addEventListener('keydown', this);
21+
}
22+
}
23+
24+
/**
25+
* Removes the global Escape key handler for closing any open igc-tooltip instances.
26+
*/
27+
private _removeListeners(): void {
28+
if (isServer) {
29+
return;
30+
}
31+
32+
if (isEmpty(this._collection)) {
33+
document.documentElement.removeEventListener('keydown', this);
34+
}
35+
}
36+
37+
public add(instance: IgcTooltipComponent): void {
38+
if (this._collection.has(instance)) {
39+
return;
40+
}
41+
42+
this._setListeners();
43+
this._collection.set(instance, instance[hideOnTrigger]);
44+
}
45+
46+
public remove(instance: IgcTooltipComponent): void {
47+
if (!this._collection.has(instance)) {
48+
return;
49+
}
50+
51+
this._collection.delete(instance);
52+
this._removeListeners();
53+
}
54+
55+
/** @internal */
56+
public handleEvent(event: KeyboardEvent): void {
57+
if (event.key !== escapeKey) {
58+
return;
59+
}
60+
61+
const callback = last(Array.from(this._collection.values()));
62+
callback?.();
63+
}
64+
}
65+
66+
const service = new TooltipEscapeCallbacks();
67+
export default service;

0 commit comments

Comments
 (0)