Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

[PLAY-1922] Tooltip Interaction using @floating-ui #4322

Open
wants to merge 17 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions playbook/app/entrypoints/playbook-rails.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ PbPopover.start()
import PbTooltip from 'kits/pb_tooltip'
PbTooltip.start()

import PbTooltipFLoatingUi from 'kits/pb_tooltip/floating_ui'
PbTooltipFLoatingUi.start()

import PbFixedConfirmationToast from 'kits/pb_fixed_confirmation_toast'
PbFixedConfirmationToast.start()

Expand Down
3 changes: 0 additions & 3 deletions playbook/app/pb_kits/playbook/pb_tooltip/_tooltip.scss
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,6 @@ $tooltip_shadow: rgba(60, 106, 172, 0.18);

&[data-popper-placement="right"] {
box-shadow: -8px 0 28px 0 $tooltip_shadow;
margin: 0 0 0 $space_sm;
.arrow {
left: -18px;
right: auto;
Expand All @@ -156,7 +155,6 @@ $tooltip_shadow: rgba(60, 106, 172, 0.18);

&[data-popper-placement="bottom"] {
box-shadow: 0 -12px 28px 0 $tooltip_shadow;
margin: $space_sm 0 0 0;
.arrow {
top: -18px;
margin-bottom: 0;
Expand All @@ -169,7 +167,6 @@ $tooltip_shadow: rgba(60, 106, 172, 0.18);

&[data-popper-placement="left"] {
box-shadow: 8px 0 28px 0 $tooltip_shadow;
margin: 0 $space_sm 0 0;
.arrow {
margin-bottom: 0;
right: -18px;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<%= pb_rails("flex", props: { orientation: "row", gap: "md" }) do %>
<%= pb_rails("flex/flex_item", props: {margin_top: "md"}) do %>
<%= pb_rails("button", props: {classname: "tooltip-delay", text: "1s delay"}) %>
<% end %>

<%= pb_rails("flex/flex_item", props: {margin_top: "md"}) do %>
<%= pb_rails("button", props: {classname: "tooltip-open-delay", text: "Open only"}) %>
<% end %>

<%= pb_rails("flex/flex_item", props: {margin_top: "md"}) do %>
<%= pb_rails("button", props: {classname: "tooltip-close-delay", text: "Close only"}) %>
<% end %>

<%= pb_rails("tooltip", props: {
trigger_element_selector: ".tooltip-delay",
tooltip_id: "delay-tooltip",
position: 'top',
delay_open: 1000,
delay_close: 1000
}) do %>
1s open/close delay
<% end %>
<%= pb_rails("tooltip", props: {
trigger_element_selector: ".tooltip-open-delay",
tooltip_id: "open-tooltip",
position: 'top',
delay_open: 1000
}) do %>
1s open delay
<% end %>
<%= pb_rails("tooltip", props: {
trigger_element_selector: ".tooltip-close-delay",
tooltip_id: "close-tooltip",
position: 'top',
delay_close: 1000
}) do %>
1s close delay
<% end %>
<% end %>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Waits for the specified time when the event listener runs before triggering the tooltip.

The `delay_open` and `delay_close` accept numbers in milliseconds. 1 second is 1000 milliseconds.
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<%= pb_rails("flex", props: { gap: "md", wrap: true }) do %>
<%= pb_rails("flex/flex_item") do %>
<%= pb_rails("button", props: { text: "With Interaction", id: "tooltip-interaction"}) %>

<%= pb_rails("tooltip", props: {
trigger_element_selector: "#tooltip-interaction",
tooltip_id: "tooltip-with-interaction",
position: 'top',
interaction: true
}) do %>
You can copy me
<% end %>
<% end %>

<%= pb_rails("flex/flex_item") do %>
<%= pb_rails("button", props: { text: "No Interaction", id: "tooltip-no-interaction"}) %>

<%= pb_rails("tooltip", props: {
trigger_element_selector: "#tooltip-no-interaction",
tooltip_id: "tooltip-without-interaction",
position: 'top',
}) do %>
I'm just a regular tooltip
<% end %>
<% end %>
<% end %>
2 changes: 2 additions & 0 deletions playbook/app/pb_kits/playbook/pb_tooltip/docs/example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ examples:

rails:
- tooltip_default: Default
- tooltip_interaction: Content Interaction
- tooltip_selectors: Using Common Selectors
- tooltip_with_icon_circle: Icon Circle Tooltip
- tooltip_delay_rails: Delay
- tooltip_show_tooltip: Show Tooltip

react:
Expand Down
282 changes: 282 additions & 0 deletions playbook/app/pb_kits/playbook/pb_tooltip/floating_ui.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
import PbEnhancedElement from '../pb_enhanced_element'
import { computePosition, offset, flip, shift, arrow, autoUpdate } from '@floating-ui/dom'

const TOOLTIP_OFFSET = 20
const TOOLTIP_TIMEOUT = 250
const SAFE_ZONE_MARGIN = 1

export default class PbTooltipFloatingUi extends PbEnhancedElement {
static get selector() {
return '[data-pb-tooltip-kit="true"][data-pb-tooltip-delay-open], [data-pb-tooltip-kit="true"][data-pb-tooltip-delay-close], [data-pb-tooltip-kit="true"][data-pb-tooltip-interaction="true"]'
}

connect() {
if (this.tooltipInteraction) {
document.addEventListener('mousemove', (e) => {
this.lastMouseX = e.clientX
this.lastMouseY = e.clientY
})
}

this.triggerElements.forEach((trigger) => {
const method = this.triggerMethod
const interactionEnabled = this.tooltipInteraction

if (method === 'click') {
trigger.addEventListener('click', () => {
this.showTooltip(trigger)
})
} else {
trigger.addEventListener('mouseenter', () => {
clearSafeZoneListener(this)
clearTimeout(this.mouseleaveTimeout)
this.currentTrigger = trigger
const delayOpen = this.delayOpen ? parseInt(this.delayOpen) : TOOLTIP_TIMEOUT
this.mouseenterTimeout = setTimeout(() => {
this.showTooltip(trigger)
if (interactionEnabled) {
this.checkCloseTooltip(trigger)
}
}, delayOpen)
})

trigger.addEventListener('mouseleave', () => {
clearTimeout(this.mouseenterTimeout)
if (this.delayClose) {
const delayClose = parseInt(this.delayClose)
this.mouseleaveTimeout = setTimeout(() => {
if (interactionEnabled) {
this.attachSafeZoneListener()
} else {
this.hideTooltip()
}
}, delayClose)
} else {
if (interactionEnabled) {
this.attachSafeZoneListener()
} else {
this.hideTooltip()
}
}
})

if (interactionEnabled) {
this.tooltip.addEventListener('mouseenter', () => {
clearSafeZoneListener(this)
})

this.tooltip.addEventListener('mouseleave', () => {
this.attachSafeZoneListener()
})
}
}
})
}

attachSafeZoneListener() {
clearSafeZoneListener(this)
this.safeZoneHandler = (e) => {
if (!this.currentTrigger) return
const triggerRect = this.currentTrigger.getBoundingClientRect()
const tooltipRect = this.tooltip.getBoundingClientRect()
const safeRect = getSafeZone(triggerRect, tooltipRect, this.position, SAFE_ZONE_MARGIN)
if (!isPointInsideRect(e.clientX, e.clientY, safeRect)) {
this.hideTooltip()
clearSafeZoneListener(this)
}
}
document.addEventListener('mousemove', this.safeZoneHandler)
}

checkCloseTooltip(trigger) {
document.querySelector('body').addEventListener('click', ({ target }) => {
const isTooltip = target.closest(`#${this.tooltipId}`) === this.tooltip
const isTrigger = target.closest(this.triggerElementSelector) === trigger
if (isTrigger || isTooltip) {
this.checkCloseTooltip(trigger)
} else {
this.hideTooltip()
}
}, { once: true })
}

showTooltip(trigger) {
if (this.shouldShowTooltip === 'false') return

clearSafeZoneListener(this)

this.tooltip.style.opacity = '1'
this.tooltip.style.visibility = 'visible'
this.tooltip.style.pointerEvents = 'auto'

if (this.cleanup) {
this.cleanup()
}

const arrowElement = document.querySelector(`#${this.tooltipId}-arrow`)

this.cleanup = autoUpdate(trigger, this.tooltip, () => {
computePosition(trigger, this.tooltip, {
placement: this.position,
strategy: 'fixed',
middleware: [
offset({ mainAxis: TOOLTIP_OFFSET, crossAxis: 0 }),
flip(),
shift(),
arrow({ element: arrowElement })
],
}).then(({ x, y, placement, middlewareData }) => {
Object.assign(this.tooltip.style, {
left: `${x}px`,
top: `${y}px`,
position: 'fixed'
})
this.tooltip.setAttribute('data-popper-placement', placement)
if (arrowElement && middlewareData.arrow) {
const { x: arrowX, y: arrowY } = middlewareData.arrow
Object.assign(arrowElement.style, {
left: arrowX != null ? `${arrowX}px` : '',
top: arrowY != null ? `${arrowY}px` : '',
position: 'absolute'
})
}
})
})

this.tooltip.classList.add('show')

if (this.triggerMethod === 'click') {
clearTimeout(this.autoHideTimeout)
this.autoHideTimeout = setTimeout(() => {
this.hideTooltip()
}, 1000)
}
}

hideTooltip() {
if (!this.tooltip) return

this.tooltip.classList.add('fade_out')
setTimeout(() => {
if (this.cleanup) {
this.cleanup()
this.cleanup = null
}
this.tooltip.classList.remove('show')
this.tooltip.classList.remove('fade_out')
this.tooltip.style.opacity = '0'
this.tooltip.style.visibility = 'hidden'
this.tooltip.style.pointerEvents = 'none'
this.tooltip.style.position = ''
this.tooltip.style.top = ''
this.tooltip.style.left = ''
this.tooltip.style.transform = ''
}, TOOLTIP_TIMEOUT)
}

get triggerElements() {
let triggerEl
if (this.triggerElementId) {
triggerEl = document.querySelector(`#${this.triggerElementId}`)
} else if (this.triggerElementSelector) {
const selectorIsId = this.triggerElementSelector.indexOf('#') > -1
triggerEl = selectorIsId
? document.querySelector(this.triggerElementSelector)
: document.querySelectorAll(this.triggerElementSelector)
} else {
triggerEl = this.element
}
if (!triggerEl) {
console.error('Tooltip Kit: No valid trigger element found!')
return []
}
if (triggerEl.length === undefined) {
triggerEl = [triggerEl]
}
return triggerEl
}

get tooltip() {
return (this._tooltip = this._tooltip || this.element.querySelector(`#${this.tooltipId}`))
}

get position() {
return this.element.dataset.pbTooltipPosition
}

get triggerElementId() {
return this.element.dataset.pbTooltipTriggerElementId
}

get tooltipId() {
return this.element.dataset.pbTooltipTooltipId
}

get triggerElementSelector() {
return this.element.dataset.pbTooltipTriggerElementSelector
}

get shouldShowTooltip() {
return this.element.dataset.pbTooltipShowTooltip
}

get triggerMethod() {
return this.element.dataset.pbTooltipTriggerMethod || 'hover'
}

get tooltipInteraction() {
return this.element.dataset.pbTooltipInteraction === 'true'
}

get delayOpen() {
return this.element.dataset.pbTooltipDelayOpen
}

get delayClose() {
return this.element.dataset.pbTooltipDelayClose
}
}

function clearSafeZoneListener(context) {
if (context.safeZoneHandler) {
document.removeEventListener('mousemove', context.safeZoneHandler)
context.safeZoneHandler = null
}
}

function getSafeZone(triggerRect, tooltipRect, placement, margin) {
let safeRect = {}
if (placement.startsWith('top')) {
safeRect.left = triggerRect.left - margin
safeRect.right = triggerRect.right + margin
safeRect.top = tooltipRect.bottom - margin
safeRect.bottom = triggerRect.top + margin
} else if (placement.startsWith('bottom')) {
safeRect.left = triggerRect.left - margin
safeRect.right = triggerRect.right + margin
safeRect.top = triggerRect.bottom - margin
safeRect.bottom = tooltipRect.top + margin
} else if (placement.startsWith('left')) {
safeRect.top = triggerRect.top - margin
safeRect.bottom = triggerRect.bottom + margin
safeRect.left = tooltipRect.right - margin
safeRect.right = triggerRect.left + margin
} else if (placement.startsWith('right')) {
safeRect.top = triggerRect.top - margin
safeRect.bottom = triggerRect.bottom + margin
safeRect.left = triggerRect.right - margin
safeRect.right = tooltipRect.left + margin
} else {
safeRect = {
left: triggerRect.left - margin,
right: triggerRect.right + margin,
top: triggerRect.top - margin,
bottom: triggerRect.bottom + margin,
}
}
return safeRect
}

function isPointInsideRect(x, y, rect) {
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom
}
Loading
Loading