Skip to content

Commit

Permalink
feat: auto placement when obstructed by another element
Browse files Browse the repository at this point in the history
  • Loading branch information
kevinchappell committed Nov 15, 2024
1 parent 270de8b commit 1c1a789
Show file tree
Hide file tree
Showing 5 changed files with 240 additions and 21 deletions.
65 changes: 59 additions & 6 deletions src/css/demo.css
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
font-family: system-ui, sans-serif;
}

.container {
display: flex;
gap: 2rem;
flex-wrap: wrap;
padding: 2rem;
position: relative;
padding: 1rem;
height: calc(100vh - 2rem);
}

button {
Expand All @@ -20,4 +17,60 @@ button {
border: none;
border-radius: 0.25rem;
cursor: pointer;
position: absolute;
}

.center {
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}


.left {
left: 0;
top: 50%;
transform: translate(0, -50%);
}

.top-left {
left: 0;
top: 0;
transform: translate(0, 0);
}

.top {
left: 50%;
top: 0;
transform: translate(-50%, 0);
}

.top-right {
right: 0;
top: 0;
transform: translate(0, 0);
}

.right {
right: 0;
top: 50%;
transform: translate(0, -50%);
}

.bottom-right {
right: 0;
bottom: 0;
transform: translate(0, 0);
}

.bottom {
left: 50%;
bottom: 0;
transform: translate(-50%, 0);
}

.bottom-left {
left: 0;
bottom: 0;
transform: translate(0, 0);
}
34 changes: 34 additions & 0 deletions src/css/tooltip.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,15 @@
visibility: hidden;
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
left: 0;
top: 0;
}

.tooltip.visible {
visibility: visible;
opacity: 1;
pointer-events: all;
}

.tooltip::before {
Expand Down Expand Up @@ -51,3 +55,33 @@
top: 50%;
transform: translateY(-50%);
}

.tooltip[data-position="top-left"]::before {
border-top-color: #1f2937;
bottom: -12px;
left: 12px;
transform: none;
}

.tooltip[data-position="top-right"]::before {
border-top-color: #1f2937;
bottom: -12px;
right: 12px;
left: auto;
transform: none;
}

.tooltip[data-position="bottom-left"]::before {
border-bottom-color: #1f2937;
top: -12px;
left: 12px;
transform: none;
}

.tooltip[data-position="bottom-right"]::before {
border-bottom-color: #1f2937;
top: -12px;
right: 12px;
left: auto;
transform: none;
}
43 changes: 35 additions & 8 deletions src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,49 @@

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

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

<button style="align-self: flex-end;" data-tooltip="This tooltip will only show on click" data-tooltip-type="click">
Click Me
<button class="top-right"
data-tooltip="Tooltip will position itself on bottom left when the top right is obstructed">
Hover (bottom-left)
</button>

<button class="right" data-tooltip="Tooltip will position itself on the left when the right is obstructed">
Hover (right-middle)
</button>

<button class="left" data-tooltip="Tooltip will position itself on the right when the left is obstructed">
Hover (left-middle)
</button>

<button class="bottom-left"
data-tooltip="Tooltip will position itself on top left when the bottom right is obstructed">
Hover (bottom-right)
</button>

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

<button class="bottom-right"
data-tooltip="Tooltip will position itself on top right when the top left is obstructed">
Hover (bottom-left)
</button>

<button class="center"
data-tooltip="<strong>Hello!</strong><br>This is a tooltip with <em>HTML</em> content and it only appears when clicked."
data-tooltip-type="click">
Click Me
</button>


</div>

</body>
Expand Down
108 changes: 104 additions & 4 deletions src/js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ interface SmartTooltipOptions {
}

interface Position {
name: 'top' | 'bottom' | 'left' | 'right'
name: 'top' | 'bottom' | 'left' | 'right' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
x: number
y: number
}
Expand All @@ -14,6 +14,42 @@ const defaultOptions = {
triggerName: 'tooltip',
}

/**
* The `SmartTooltip` class provides functionality to display tooltips on HTML elements.
* Tooltips can be triggered by mouse hover or click events and are positioned optimally
* within the viewport to avoid overflow.
*
* @example
* ```typescript
* const tooltip = new SmartTooltip({
* triggerName: 'tooltip'
* });
* ```
*
* @remarks
* The tooltip content is specified using a data attribute on the trigger element.
* The tooltip can be triggered by elements with the specified `triggerName` data attribute.
*
* @param {SmartTooltipOptions} options - Configuration options for the tooltip.
*
* @property {string} triggerName - The name of the data attribute used to trigger the tooltip.
* @property {HTMLDivElement} tooltip - The tooltip element.
* @property {string | null} activeTriggerType - The type of the currently active trigger ('click' or 'hover').
* @property {number} spacing - The spacing between the tooltip and the trigger element.
*
* @method setupEventListeners - Sets up event listeners for mouseover, mouseout, click, resize, and scroll events.
* @method handleClick - Handles click events to show or hide the tooltip.
* @method handleMouseOver - Handles mouseover events to show the tooltip.
* @method handleMouseOut - Handles mouseout events to hide the tooltip.
* @method handleResize - Handles window resize events to hide the tooltip.
* @method handleScroll - Handles window scroll events to hide the tooltip.
* @method isVisible - Checks if the tooltip is currently visible.
* @method calculatePosition - Calculates the optimal position for the tooltip relative to the trigger element.
* @method fitsInViewport - Checks if the tooltip fits within the viewport and is not obstructed by other elements.
* @method show - Displays the tooltip with the specified content.
* @method hide - Hides the tooltip.
* @method destroy - Removes event listeners and the tooltip element from the DOM.
*/
export class SmartTooltip {
readonly triggerName: string
private readonly tooltip: HTMLDivElement
Expand All @@ -23,7 +59,7 @@ export class SmartTooltip {
constructor(options: SmartTooltipOptions = defaultOptions) {
this.triggerName = `data-${options.triggerName}`
this.tooltip = document.createElement('div')
this.tooltip.className = styles.tooltip
this.tooltip.className = `d-tooltip ${styles.tooltip}`
document.body.appendChild(this.tooltip)

this.setupEventListeners()
Expand Down Expand Up @@ -89,6 +125,14 @@ export class SmartTooltip {
return this.tooltip.classList.contains(styles.visible)
}

/**
* Calculates the optimal position for the tooltip relative to the trigger element.
* It tries to find a position where the tooltip fits within the viewport.
* If no position fits, it defaults to the first position in the list.
*
* @param {HTMLElement} trigger - The HTML element that triggers the tooltip.
* @returns {Position} The calculated position for the tooltip.
*/
private calculatePosition(trigger: HTMLElement): Position {
const triggerRect = trigger.getBoundingClientRect()
const tooltipRect = this.tooltip.getBoundingClientRect()
Expand All @@ -114,18 +158,74 @@ export class SmartTooltip {
x: triggerRect.right + this.spacing,
y: triggerRect.top + (triggerRect.height - tooltipRect.height) / 2,
},
// Corner positions
{
name: 'top-left',
x: triggerRect.left,
y: triggerRect.top - tooltipRect.height - this.spacing,
},
{
name: 'top-right',
x: triggerRect.right - tooltipRect.width,
y: triggerRect.top - tooltipRect.height - this.spacing,
},
{
name: 'bottom-left',
x: triggerRect.left,
y: triggerRect.bottom + this.spacing,
},
{
name: 'bottom-right',
x: triggerRect.right - tooltipRect.width,
y: triggerRect.bottom + this.spacing,
},
]

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

/**
* Checks if the tooltip fits within the viewport and is not obstructed by other elements.
*
* @param pos - The position of the tooltip.
* @param tooltipRect - The bounding rectangle of the tooltip.
* @returns `true` if the tooltip fits within the viewport and is not obstructed, otherwise `false`.
*/
private fitsInViewport(pos: Position, tooltipRect: DOMRect): boolean {
return (
// First check if tooltip is within viewport bounds
const inViewport =
pos.x >= 0 &&
pos.y >= 0 &&
pos.x + tooltipRect.width <= window.innerWidth &&
pos.y + tooltipRect.height <= window.innerHeight
)

if (!inViewport) return false

// Check if tooltip is obstructed by other elements
const points = [
[pos.x, pos.y], // Top-left
[pos.x + tooltipRect.width, pos.y], // Top-right
[pos.x, pos.y + tooltipRect.height], // Bottom-left
[pos.x + tooltipRect.width, pos.y + tooltipRect.height], // Bottom-right
[pos.x + tooltipRect.width / 2, pos.y + tooltipRect.height / 2], // Center
]

// Get all elements at these points
const elementsAtPoints = points.flatMap(([x, y]) => Array.from(document.elementsFromPoint(x, y)))

// Filter out non-relevant elements
const obstructingElements = elementsAtPoints.filter(element => {
if (
this.tooltip.contains(element) || // Exclude tooltip and its children
element === this.tooltip ||
element.classList.contains(styles.tooltip) || // Ignore other tooltips
getComputedStyle(element).pointerEvents === 'none' // Ignore non-interactive elements
) {
return false
}
})

return obstructingElements.length === 0
}

private show(trigger: HTMLElement, content: string | null) {
Expand Down
11 changes: 8 additions & 3 deletions tools/test-setup.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ const { basename, join, dirname } = require('node:path')
const { JSDOM } = require('jsdom')

const { window } = new JSDOM('<!DOCTYPE html><p>Hello World</p>', { pretendToBeVisual: true })
global.window = window
global.document = window.document
global.navigator = window.navigator

window.document.elementsFromPoint = function elementsFromPoint() {
return []
}

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

global.window = window
global.document = window.document
global.navigator = window.navigator

snapshot.setResolveSnapshotPath(testFile => join(dirname(testFile), '__snapshots__', `${basename(testFile)}.snapshot`))

0 comments on commit 1c1a789

Please sign in to comment.