Skip to content

Commit d1462f1

Browse files
UI: Info Button component (#4829)
* UI: Info Button component Info button that displays a custom component on hover. * Extend tooltip Optionally, toggle the tooltip on click. If enabled, clicking outside will close the tooltip * Click outside: Broaden the elements that can be ignored Broaden the types of elements that can be ignored * Info Button: Ignore the SVG when checking for clicking outside * Info Button: Add Storybook * Info Button: Use a separate component for the tooltip arrow That way we can set the border-radius easliy, plus it's probably a bit more CSS-compatible with picky browser engines * TooltipWrapper shared component Factor out the common logic of the Tooltip and Info Button so that there is no duplicated functionality and so that the Info Button doesn't have to depend on the Tooltip * Tooltip and the InfoButton use the TooltipWrapper Use the shared component. Also: - Info button is opened (and stays open) on hover * separate tooltip components * icon transition effect added * button tooltip to the body level + utils function * Remove log --------- Co-authored-by: Pavel Laptev <[email protected]>
1 parent 68f0a3c commit d1462f1

File tree

8 files changed

+430
-108
lines changed

8 files changed

+430
-108
lines changed
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
<script lang="ts">
2+
import { portal } from '$lib/utils/portal';
3+
import { setPosition } from '$lib/utils/tooltipPosition';
4+
import { flyScale } from '$lib/utils/transitions';
5+
import type { Snippet } from 'svelte';
6+
7+
interface Props {
8+
title?: string;
9+
size?: 'small' | 'medium';
10+
children: Snippet;
11+
}
12+
13+
const { title, size = 'medium', children }: Props = $props();
14+
15+
let targetEl: HTMLElement | undefined = $state();
16+
let show = $state(false);
17+
let timeoutId: undefined | ReturnType<typeof setTimeout> = $state();
18+
let isHoveringCard = false; // Track if the tooltip card is hovered
19+
const gapDelay = 150; // Delay to allow transitioning between button and card
20+
21+
function handleMouseEnter() {
22+
clearTimeout(timeoutId);
23+
timeoutId = setTimeout(() => {
24+
show = true;
25+
}, 500);
26+
}
27+
28+
function handleMouseLeave() {
29+
clearTimeout(timeoutId);
30+
timeoutId = setTimeout(() => {
31+
if (!isHoveringCard) {
32+
show = false;
33+
}
34+
}, gapDelay);
35+
}
36+
37+
function handleCardMouseEnter() {
38+
clearTimeout(timeoutId);
39+
isHoveringCard = true;
40+
}
41+
42+
function handleCardMouseLeave() {
43+
isHoveringCard = false;
44+
timeoutId = setTimeout(() => {
45+
if (!isHoveringCard) {
46+
show = false;
47+
}
48+
}, gapDelay);
49+
}
50+
</script>
51+
52+
<div
53+
bind:this={targetEl}
54+
class="wrapper {size}"
55+
role="tooltip"
56+
onmouseenter={handleMouseEnter}
57+
onmouseleave={handleMouseLeave}
58+
>
59+
<div class="info-button" class:button-hovered={show}></div>
60+
61+
{#if show}
62+
<div
63+
use:portal={'body'}
64+
use:setPosition={{ targetEl, position: 'bottom', align: 'center' }}
65+
class="tooltip-container"
66+
role="presentation"
67+
transition:flyScale
68+
onmouseenter={handleCardMouseEnter}
69+
onmouseleave={handleCardMouseLeave}
70+
>
71+
<div class="tooltip-arrow"></div>
72+
73+
<div class="tooltip-card">
74+
{#if title}
75+
<h3 class="text-13 text-semibold tooltip-title">{title}</h3>
76+
{/if}
77+
<p class="text-12 text-body tooltip-description">
78+
{@render children()}
79+
</p>
80+
</div>
81+
</div>
82+
{/if}
83+
</div>
84+
85+
<style lang="postcss">
86+
.wrapper {
87+
position: relative;
88+
display: inline-flex;
89+
}
90+
91+
.info-button {
92+
position: relative;
93+
flex-shrink: 0;
94+
color: var(--clr-text-2);
95+
border-radius: 16px;
96+
box-shadow: inset 0 0 0 1.5px var(--clr-text-2);
97+
transition: box-shadow var(--transition-fast);
98+
99+
&::before,
100+
&::after {
101+
content: '';
102+
position: absolute;
103+
left: 50%;
104+
transform: translateX(-50%);
105+
background-color: var(--clr-text-2);
106+
border-radius: 2px;
107+
transition: background-color var(--transition-fast);
108+
}
109+
}
110+
111+
.button-hovered {
112+
box-shadow: inset 0 0 0 10px var(--clr-text-2);
113+
114+
&::before,
115+
&::after {
116+
background-color: var(--clr-scale-ntrl-100);
117+
}
118+
}
119+
120+
.wrapper.medium {
121+
transform: translateY(20%);
122+
123+
& .info-button {
124+
width: 16px;
125+
height: 16px;
126+
127+
&::before {
128+
top: 4px;
129+
width: 2px;
130+
height: 2px;
131+
}
132+
133+
&::after {
134+
top: 7px;
135+
width: 2px;
136+
height: 5px;
137+
}
138+
}
139+
}
140+
141+
.wrapper.small {
142+
transform: translateY(10%);
143+
144+
& .info-button {
145+
width: 12px;
146+
height: 12px;
147+
148+
&::before {
149+
top: 3px;
150+
width: 2px;
151+
height: 2px;
152+
}
153+
154+
&::after {
155+
top: 6px;
156+
width: 2px;
157+
height: 3px;
158+
}
159+
}
160+
}
161+
162+
.tooltip-container {
163+
z-index: var(--z-blocker);
164+
position: absolute;
165+
display: flex;
166+
flex-direction: column;
167+
width: fit-content;
168+
}
169+
170+
.tooltip-card {
171+
display: flex;
172+
flex-direction: column;
173+
gap: 4px;
174+
background-color: var(--clr-bg-1);
175+
border: 1px solid var(--clr-border-2);
176+
border-radius: var(--radius-m);
177+
padding: 12px;
178+
width: max-content;
179+
max-width: 260px;
180+
box-shadow: var(--fx-shadow-m);
181+
}
182+
183+
.tooltip-title {
184+
color: var(--clr-text-1);
185+
}
186+
187+
.tooltip-description {
188+
color: var(--clr-scale-ntrl-40);
189+
}
190+
191+
.tooltip-arrow {
192+
position: relative;
193+
top: 1px;
194+
margin: 0 auto;
195+
width: 100%;
196+
height: 10px;
197+
display: flex;
198+
justify-content: center;
199+
overflow: hidden;
200+
z-index: var(--z-lifted);
201+
width: fit-content;
202+
203+
&::before {
204+
content: '';
205+
position: relative;
206+
top: 4px;
207+
width: 20px;
208+
height: 20px;
209+
transform: rotate(45deg);
210+
border-radius: 2px;
211+
background-color: var(--clr-bg-1);
212+
border: 1px solid var(--clr-border-2);
213+
}
214+
}
215+
</style>

packages/ui/src/lib/Tooltip.svelte

Lines changed: 2 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
<script lang="ts">
77
import { portal } from '$lib/utils/portal';
8+
import { setPosition } from '$lib/utils/tooltipPosition';
89
import { flyScale } from '$lib/utils/transitions';
910
import { type Snippet } from 'svelte';
1011
@@ -37,106 +38,6 @@
3738
clearTimeout(timeoutId);
3839
show = false;
3940
}
40-
41-
function isNoSpaceOnRight() {
42-
if (!targetEl || !tooltipEl) return false;
43-
44-
const tooltipRect = tooltipEl.getBoundingClientRect();
45-
const targetChild = targetEl.children[0];
46-
const targetRect = targetChild.getBoundingClientRect();
47-
48-
return targetRect.left + tooltipRect.width / 2 > window.innerWidth;
49-
}
50-
51-
function isNoSpaceOnLeft() {
52-
if (!targetEl || !tooltipEl) return false;
53-
54-
const tooltipRect = tooltipEl.getBoundingClientRect();
55-
const targetChild = targetEl.children[0];
56-
const targetRect = targetChild.getBoundingClientRect();
57-
58-
return targetRect.left - tooltipRect.width / 2 < 0;
59-
}
60-
61-
function adjustPosition() {
62-
if (!targetEl || !tooltipEl) return;
63-
64-
const tooltipRect = tooltipEl.getBoundingClientRect();
65-
// get first child of targetEl
66-
const targetChild = targetEl.children[0];
67-
const targetRect = targetChild.getBoundingClientRect();
68-
69-
let top = 0;
70-
let left = 0;
71-
let transformOriginTop = 'center';
72-
let transformOriginLeft = 'center';
73-
const gap = 4;
74-
75-
function alignLeft() {
76-
left = targetRect.left + window.scrollX;
77-
transformOriginLeft = 'left';
78-
}
79-
80-
function alignRight() {
81-
left = targetRect.right - tooltipRect.width + window.scrollX;
82-
transformOriginLeft = 'right';
83-
}
84-
85-
function alignCenter() {
86-
left = targetRect.left + targetRect.width / 2 - tooltipRect.width / 2 + window.scrollX;
87-
transformOriginLeft = 'center';
88-
}
89-
90-
function positionTop() {
91-
top = targetRect.top - tooltipRect.height + window.scrollY - gap;
92-
transformOriginTop = 'bottom';
93-
}
94-
95-
function positionBottom() {
96-
top = targetRect.bottom + window.scrollY + gap;
97-
transformOriginTop = 'top';
98-
}
99-
100-
// Vertical position
101-
if (position) {
102-
if (position === 'bottom') {
103-
positionBottom();
104-
} else if (position === 'top') {
105-
positionTop();
106-
}
107-
} else {
108-
positionBottom();
109-
}
110-
111-
// Auto check horizontal position
112-
if (align) {
113-
if (align === 'start') {
114-
alignLeft();
115-
} else if (align === 'end') {
116-
alignRight();
117-
} else if (align === 'center') {
118-
alignCenter();
119-
}
120-
} else {
121-
if (isNoSpaceOnLeft()) {
122-
alignLeft();
123-
} else if (isNoSpaceOnRight()) {
124-
alignRight();
125-
} else {
126-
alignCenter();
127-
}
128-
}
129-
130-
tooltipEl.style.top = `${top}px`;
131-
tooltipEl.style.left = `${left}px`;
132-
tooltipEl.style.transformOrigin = `${transformOriginTop} ${transformOriginLeft}`;
133-
}
134-
135-
$effect(() => {
136-
if (tooltipEl) {
137-
adjustPosition();
138-
}
139-
});
14041
</script>
14142

14243
{#if isTextEmpty}
@@ -156,6 +57,7 @@
15657
{#if show}
15758
<div
15859
bind:this={tooltipEl}
60+
use:setPosition={{ targetEl, position, align }}
15961
use:portal={'body'}
16062
class="tooltip-container text-11 text-body"
16163
transition:flyScale={{

packages/ui/src/lib/utils/clickOutside.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export type ClickOpts = { excludeElement?: HTMLElement; handler: () => void };
1+
export type ClickOpts = { excludeElement?: Element; handler: () => void };
22

33
export function clickOutside(node: HTMLElement, params: ClickOpts) {
44
function onClick(event: MouseEvent) {

0 commit comments

Comments
 (0)