Skip to content

Commit 40e13e2

Browse files
RivaIvanovarkaraivanovZneekydidimmovasimeonoff
authored
feat: Added tooltip component (#1621)
* feat: Added tooltip component --------- Co-authored-by: Radoslav Karaivanov <[email protected]> Co-authored-by: Arkan Ahmedov <[email protected]> Co-authored-by: didimmova <[email protected]> Co-authored-by: Dilyana Yarabanova <[email protected]> Co-authored-by: Simeon Simeonoff <[email protected]>
1 parent c7921cc commit 40e13e2

28 files changed

+2642
-13
lines changed

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
66

77
## [Unreleased]
88
### Added
9-
- New File Input Component(`igc-file-input`)
9+
- File Input component
1010
- Exposed more public API type aliases for component property types like `ButtonVariant`, `PickerMode`, `StepperOrientation`, `HorizontalTransitionAnimation` (carousel and horizontal stepper) and more.
11+
- Tooltip component
1112

1213
### Deprecated
1314
- Some event argument types have been renamed for consistency:

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/animations/presets/scale/index.ts

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/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: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -292,10 +292,36 @@ 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
/**
296-
* Returns whether a given collection has at least one member.
322+
* Returns whether a given collection is empty.
297323
*/
298-
export function isEmpty<T, U extends string>(
324+
export function isEmpty<T, U extends object>(
299325
x: ArrayLike<T> | Set<T> | Map<U, T>
300326
): boolean {
301327
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: 89 additions & 10 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,
@@ -59,7 +64,7 @@ export default class IgcPopoverComponent extends LitElement {
5964
private dispose?: ReturnType<typeof autoUpdate>;
6065
private target?: Element;
6166

62-
@query('#container', true)
67+
@query('#container')
6368
private _container!: HTMLElement;
6469

6570
@queryAssignedElements({ slot: 'anchor', flatten: true })
@@ -72,6 +77,23 @@ 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+
/** Additional offset to apply to the arrow element if enabled. */
87+
@property({ type: Number, attribute: 'arrow-offset' })
88+
public arrowOffset = 0;
89+
90+
/**
91+
* Improves positioning for inline reference elements that span over multiple lines.
92+
* Useful for tooltips or similar components.
93+
*/
94+
@property({ type: Boolean, reflect: true })
95+
public inline = false;
96+
7597
/**
7698
* When enabled this changes the placement of the floating element in order to keep it
7799
* in view along the main axis.
@@ -110,8 +132,14 @@ export default class IgcPopoverComponent extends LitElement {
110132
@property({ type: Boolean, reflect: true })
111133
public shift = false;
112134

135+
/**
136+
* Virtual padding for the resolved overflow detection offsets in pixels.
137+
*/
138+
@property({ type: Number, attribute: 'shift-padding' })
139+
public shiftPadding = 0;
140+
113141
@watch('anchor')
114-
protected async anchorChange() {
142+
protected anchorChange() {
115143
const newTarget = isString(this.anchor)
116144
? getElementByIdFromRoot(this, this.anchor)
117145
: this.anchor;
@@ -127,11 +155,15 @@ export default class IgcPopoverComponent extends LitElement {
127155
this.open ? this.show() : this.hide();
128156
}
129157

158+
@watch('arrow', { waitUntilFirstUpdate: true })
159+
@watch('arrowOffset', { waitUntilFirstUpdate: true })
130160
@watch('flip', { waitUntilFirstUpdate: true })
161+
@watch('inline', { waitUntilFirstUpdate: true })
131162
@watch('offset', { waitUntilFirstUpdate: true })
132163
@watch('placement', { waitUntilFirstUpdate: true })
133164
@watch('sameWidth', { waitUntilFirstUpdate: true })
134165
@watch('shift', { waitUntilFirstUpdate: true })
166+
@watch('shiftPadding', { waitUntilFirstUpdate: true })
135167
protected floatingPropChange() {
136168
this._updateState();
137169
}
@@ -151,7 +183,10 @@ export default class IgcPopoverComponent extends LitElement {
151183
}
152184

153185
protected show() {
154-
if (!this.target) return;
186+
if (!this.target) {
187+
return;
188+
}
189+
155190
this._showPopover();
156191

157192
this.dispose = autoUpdate(
@@ -187,14 +222,23 @@ export default class IgcPopoverComponent extends LitElement {
187222
middleware.push(offset(this.offset));
188223
}
189224

225+
if (this.inline) {
226+
middleware.push(inline());
227+
}
228+
190229
if (this.shift) {
191230
middleware.push(
192231
shift({
232+
padding: this.shiftPadding,
193233
limiter: limitShift(),
194234
})
195235
);
196236
}
197237

238+
if (this.arrow) {
239+
middleware.push(arrow({ element: this.arrow }));
240+
}
241+
198242
if (this.flip) {
199243
middleware.push(flip());
200244
}
@@ -222,25 +266,60 @@ export default class IgcPopoverComponent extends LitElement {
222266
}
223267

224268
private async _updatePosition() {
225-
if (!this.open || !this.target) {
269+
if (!(this.open && this.target)) {
226270
return;
227271
}
228272

229-
const { x, y } = await computePosition(this.target, this._container, {
230-
placement: this.placement ?? 'bottom-start',
231-
middleware: this._createMiddleware(),
232-
strategy: 'fixed',
233-
});
273+
const { x, y, middlewareData, placement } = await computePosition(
274+
this.target,
275+
this._container,
276+
{
277+
placement: this.placement ?? 'bottom-start',
278+
middleware: this._createMiddleware(),
279+
strategy: 'fixed',
280+
}
281+
);
234282

235283
Object.assign(this._container.style, {
236284
left: 0,
237285
top: 0,
238286
transform: `translate(${roundByDPR(x)}px,${roundByDPR(y)}px)`,
239287
});
288+
289+
this._positionArrow(placement, middlewareData);
290+
}
291+
292+
private _positionArrow(placement: Placement, data: MiddlewareData) {
293+
if (!(data.arrow && this.arrow)) {
294+
return;
295+
}
296+
297+
const { x, y } = data.arrow;
298+
299+
// The current placement of the popover along the x/y axis
300+
const currentPlacement = first(placement.split('-'));
301+
302+
// The opposite side where the arrow element should render based on the `currentPlacement`
303+
const staticSide = {
304+
top: 'bottom',
305+
right: 'left',
306+
bottom: 'top',
307+
left: 'right',
308+
}[currentPlacement]!;
309+
310+
this.arrow.part = currentPlacement;
311+
312+
Object.assign(this.arrow.style, {
313+
left: x != null ? `${roundByDPR(x + this.arrowOffset)}px` : '',
314+
top: y != null ? `${roundByDPR(y + this.arrowOffset)}px` : '',
315+
[staticSide]: '-4px',
316+
});
240317
}
241318

242319
private _anchorSlotChange() {
243-
if (this.anchor || isEmpty(this._anchors)) return;
320+
if (this.anchor || isEmpty(this._anchors)) {
321+
return;
322+
}
244323

245324
this.target = this._anchors[0];
246325
this._updateState();

0 commit comments

Comments
 (0)