Skip to content

Commit 1c1a789

Browse files
committed
feat: auto placement when obstructed by another element
1 parent 270de8b commit 1c1a789

File tree

5 files changed

+240
-21
lines changed

5 files changed

+240
-21
lines changed

src/css/demo.css

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,13 @@
11
body {
22
margin: 0;
33
min-height: 100vh;
4-
display: grid;
5-
place-items: center;
64
font-family: system-ui, sans-serif;
75
}
86

97
.container {
10-
display: flex;
11-
gap: 2rem;
12-
flex-wrap: wrap;
13-
padding: 2rem;
8+
position: relative;
9+
padding: 1rem;
10+
height: calc(100vh - 2rem);
1411
}
1512

1613
button {
@@ -20,4 +17,60 @@ button {
2017
border: none;
2118
border-radius: 0.25rem;
2219
cursor: pointer;
20+
position: absolute;
21+
}
22+
23+
.center {
24+
left: 50%;
25+
top: 50%;
26+
transform: translate(-50%, -50%);
27+
}
28+
29+
30+
.left {
31+
left: 0;
32+
top: 50%;
33+
transform: translate(0, -50%);
34+
}
35+
36+
.top-left {
37+
left: 0;
38+
top: 0;
39+
transform: translate(0, 0);
40+
}
41+
42+
.top {
43+
left: 50%;
44+
top: 0;
45+
transform: translate(-50%, 0);
46+
}
47+
48+
.top-right {
49+
right: 0;
50+
top: 0;
51+
transform: translate(0, 0);
52+
}
53+
54+
.right {
55+
right: 0;
56+
top: 50%;
57+
transform: translate(0, -50%);
58+
}
59+
60+
.bottom-right {
61+
right: 0;
62+
bottom: 0;
63+
transform: translate(0, 0);
64+
}
65+
66+
.bottom {
67+
left: 50%;
68+
bottom: 0;
69+
transform: translate(-50%, 0);
70+
}
71+
72+
.bottom-left {
73+
left: 0;
74+
bottom: 0;
75+
transform: translate(0, 0);
2376
}

src/css/tooltip.module.css

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,15 @@
99
visibility: hidden;
1010
opacity: 0;
1111
transition: opacity 0.2s;
12+
pointer-events: none;
13+
left: 0;
14+
top: 0;
1215
}
1316

1417
.tooltip.visible {
1518
visibility: visible;
1619
opacity: 1;
20+
pointer-events: all;
1721
}
1822

1923
.tooltip::before {
@@ -51,3 +55,33 @@
5155
top: 50%;
5256
transform: translateY(-50%);
5357
}
58+
59+
.tooltip[data-position="top-left"]::before {
60+
border-top-color: #1f2937;
61+
bottom: -12px;
62+
left: 12px;
63+
transform: none;
64+
}
65+
66+
.tooltip[data-position="top-right"]::before {
67+
border-top-color: #1f2937;
68+
bottom: -12px;
69+
right: 12px;
70+
left: auto;
71+
transform: none;
72+
}
73+
74+
.tooltip[data-position="bottom-left"]::before {
75+
border-bottom-color: #1f2937;
76+
top: -12px;
77+
left: 12px;
78+
transform: none;
79+
}
80+
81+
.tooltip[data-position="bottom-right"]::before {
82+
border-bottom-color: #1f2937;
83+
top: -12px;
84+
right: 12px;
85+
left: auto;
86+
transform: none;
87+
}

src/index.html

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,22 +13,49 @@
1313

1414
<body>
1515
<div class="container">
16-
<button data-tooltip="<strong>Hello!</strong><br>This is a tooltip with <em>HTML</em> content.">
17-
Hover me (center)
16+
<button class="top-left"
17+
data-tooltip="Tooltip will position itself on bottom right when the top left is obstructed">
18+
Hover (bottom-right)
1819
</button>
1920

20-
<button style="align-self: flex-start;" data-tooltip="This tooltip will adjust its position when near the top">
21-
Hover me (top)
21+
<button class="top" data-tooltip="Tooltip will position itself on bottom when the top is obstructed">
22+
Hover (bottom-center)
2223
</button>
2324

24-
<button style="align-self: flex-end;" data-tooltip="This tooltip will only show on click" data-tooltip-type="click">
25-
Click Me
25+
<button class="top-right"
26+
data-tooltip="Tooltip will position itself on bottom left when the top right is obstructed">
27+
Hover (bottom-left)
28+
</button>
29+
30+
<button class="right" data-tooltip="Tooltip will position itself on the left when the right is obstructed">
31+
Hover (right-middle)
32+
</button>
33+
34+
<button class="left" data-tooltip="Tooltip will position itself on the right when the left is obstructed">
35+
Hover (left-middle)
36+
</button>
37+
38+
<button class="bottom-left"
39+
data-tooltip="Tooltip will position itself on top left when the bottom right is obstructed">
40+
Hover (bottom-right)
2641
</button>
2742

28-
<button style="align-self: flex-end;" data-tooltip="This tooltip will adjust when near the bottom">
29-
Hover me (bottom)
43+
<button class="bottom" data-tooltip="Tooltip will position itself on the top when the bottom is obstructed">
44+
Hover (bottom-center)
3045
</button>
3146

47+
<button class="bottom-right"
48+
data-tooltip="Tooltip will position itself on top right when the top left is obstructed">
49+
Hover (bottom-left)
50+
</button>
51+
52+
<button class="center"
53+
data-tooltip="<strong>Hello!</strong><br>This is a tooltip with <em>HTML</em> content and it only appears when clicked."
54+
data-tooltip-type="click">
55+
Click Me
56+
</button>
57+
58+
3259
</div>
3360

3461
</body>

src/js/index.ts

Lines changed: 104 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ interface SmartTooltipOptions {
55
}
66

77
interface Position {
8-
name: 'top' | 'bottom' | 'left' | 'right'
8+
name: 'top' | 'bottom' | 'left' | 'right' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
99
x: number
1010
y: number
1111
}
@@ -14,6 +14,42 @@ const defaultOptions = {
1414
triggerName: 'tooltip',
1515
}
1616

17+
/**
18+
* The `SmartTooltip` class provides functionality to display tooltips on HTML elements.
19+
* Tooltips can be triggered by mouse hover or click events and are positioned optimally
20+
* within the viewport to avoid overflow.
21+
*
22+
* @example
23+
* ```typescript
24+
* const tooltip = new SmartTooltip({
25+
* triggerName: 'tooltip'
26+
* });
27+
* ```
28+
*
29+
* @remarks
30+
* The tooltip content is specified using a data attribute on the trigger element.
31+
* The tooltip can be triggered by elements with the specified `triggerName` data attribute.
32+
*
33+
* @param {SmartTooltipOptions} options - Configuration options for the tooltip.
34+
*
35+
* @property {string} triggerName - The name of the data attribute used to trigger the tooltip.
36+
* @property {HTMLDivElement} tooltip - The tooltip element.
37+
* @property {string | null} activeTriggerType - The type of the currently active trigger ('click' or 'hover').
38+
* @property {number} spacing - The spacing between the tooltip and the trigger element.
39+
*
40+
* @method setupEventListeners - Sets up event listeners for mouseover, mouseout, click, resize, and scroll events.
41+
* @method handleClick - Handles click events to show or hide the tooltip.
42+
* @method handleMouseOver - Handles mouseover events to show the tooltip.
43+
* @method handleMouseOut - Handles mouseout events to hide the tooltip.
44+
* @method handleResize - Handles window resize events to hide the tooltip.
45+
* @method handleScroll - Handles window scroll events to hide the tooltip.
46+
* @method isVisible - Checks if the tooltip is currently visible.
47+
* @method calculatePosition - Calculates the optimal position for the tooltip relative to the trigger element.
48+
* @method fitsInViewport - Checks if the tooltip fits within the viewport and is not obstructed by other elements.
49+
* @method show - Displays the tooltip with the specified content.
50+
* @method hide - Hides the tooltip.
51+
* @method destroy - Removes event listeners and the tooltip element from the DOM.
52+
*/
1753
export class SmartTooltip {
1854
readonly triggerName: string
1955
private readonly tooltip: HTMLDivElement
@@ -23,7 +59,7 @@ export class SmartTooltip {
2359
constructor(options: SmartTooltipOptions = defaultOptions) {
2460
this.triggerName = `data-${options.triggerName}`
2561
this.tooltip = document.createElement('div')
26-
this.tooltip.className = styles.tooltip
62+
this.tooltip.className = `d-tooltip ${styles.tooltip}`
2763
document.body.appendChild(this.tooltip)
2864

2965
this.setupEventListeners()
@@ -89,6 +125,14 @@ export class SmartTooltip {
89125
return this.tooltip.classList.contains(styles.visible)
90126
}
91127

128+
/**
129+
* Calculates the optimal position for the tooltip relative to the trigger element.
130+
* It tries to find a position where the tooltip fits within the viewport.
131+
* If no position fits, it defaults to the first position in the list.
132+
*
133+
* @param {HTMLElement} trigger - The HTML element that triggers the tooltip.
134+
* @returns {Position} The calculated position for the tooltip.
135+
*/
92136
private calculatePosition(trigger: HTMLElement): Position {
93137
const triggerRect = trigger.getBoundingClientRect()
94138
const tooltipRect = this.tooltip.getBoundingClientRect()
@@ -114,18 +158,74 @@ export class SmartTooltip {
114158
x: triggerRect.right + this.spacing,
115159
y: triggerRect.top + (triggerRect.height - tooltipRect.height) / 2,
116160
},
161+
// Corner positions
162+
{
163+
name: 'top-left',
164+
x: triggerRect.left,
165+
y: triggerRect.top - tooltipRect.height - this.spacing,
166+
},
167+
{
168+
name: 'top-right',
169+
x: triggerRect.right - tooltipRect.width,
170+
y: triggerRect.top - tooltipRect.height - this.spacing,
171+
},
172+
{
173+
name: 'bottom-left',
174+
x: triggerRect.left,
175+
y: triggerRect.bottom + this.spacing,
176+
},
177+
{
178+
name: 'bottom-right',
179+
x: triggerRect.right - tooltipRect.width,
180+
y: triggerRect.bottom + this.spacing,
181+
},
117182
]
118183

119184
return positions.find(pos => this.fitsInViewport(pos, tooltipRect)) || positions[0]
120185
}
121186

187+
/**
188+
* Checks if the tooltip fits within the viewport and is not obstructed by other elements.
189+
*
190+
* @param pos - The position of the tooltip.
191+
* @param tooltipRect - The bounding rectangle of the tooltip.
192+
* @returns `true` if the tooltip fits within the viewport and is not obstructed, otherwise `false`.
193+
*/
122194
private fitsInViewport(pos: Position, tooltipRect: DOMRect): boolean {
123-
return (
195+
// First check if tooltip is within viewport bounds
196+
const inViewport =
124197
pos.x >= 0 &&
125198
pos.y >= 0 &&
126199
pos.x + tooltipRect.width <= window.innerWidth &&
127200
pos.y + tooltipRect.height <= window.innerHeight
128-
)
201+
202+
if (!inViewport) return false
203+
204+
// Check if tooltip is obstructed by other elements
205+
const points = [
206+
[pos.x, pos.y], // Top-left
207+
[pos.x + tooltipRect.width, pos.y], // Top-right
208+
[pos.x, pos.y + tooltipRect.height], // Bottom-left
209+
[pos.x + tooltipRect.width, pos.y + tooltipRect.height], // Bottom-right
210+
[pos.x + tooltipRect.width / 2, pos.y + tooltipRect.height / 2], // Center
211+
]
212+
213+
// Get all elements at these points
214+
const elementsAtPoints = points.flatMap(([x, y]) => Array.from(document.elementsFromPoint(x, y)))
215+
216+
// Filter out non-relevant elements
217+
const obstructingElements = elementsAtPoints.filter(element => {
218+
if (
219+
this.tooltip.contains(element) || // Exclude tooltip and its children
220+
element === this.tooltip ||
221+
element.classList.contains(styles.tooltip) || // Ignore other tooltips
222+
getComputedStyle(element).pointerEvents === 'none' // Ignore non-interactive elements
223+
) {
224+
return false
225+
}
226+
})
227+
228+
return obstructingElements.length === 0
129229
}
130230

131231
private show(trigger: HTMLElement, content: string | null) {

tools/test-setup.cjs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ const { basename, join, dirname } = require('node:path')
33
const { JSDOM } = require('jsdom')
44

55
const { window } = new JSDOM('<!DOCTYPE html><p>Hello World</p>', { pretendToBeVisual: true })
6-
global.window = window
7-
global.document = window.document
8-
global.navigator = window.navigator
6+
7+
window.document.elementsFromPoint = function elementsFromPoint() {
8+
return []
9+
}
910

1011
// jsdom does not provide this method
1112
window.Element.prototype.animate = () => ({
@@ -14,4 +15,8 @@ window.Element.prototype.animate = () => ({
1415
removeEventListener: () => {},
1516
})
1617

18+
global.window = window
19+
global.document = window.document
20+
global.navigator = window.navigator
21+
1722
snapshot.setResolveSnapshotPath(testFile => join(dirname(testFile), '__snapshots__', `${basename(testFile)}.snapshot`))

0 commit comments

Comments
 (0)